diff --git a/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb b/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb new file mode 100644 index 000000000..2f45b1b0c --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Enterprise::Billing::V2::Concerns::PaymentIntentHandler + extend ActiveSupport::Concern + + private + + def create_payment_if_needed(intent, intent_id) + amount_due = intent.amount_details&.total || intent.amount_details.total + return nil unless amount_due&.to_i&.positive? + + payment_method_id = fetch_default_payment_method + create_upfront_payment_intent(amount_due, intent.currency, payment_method_id, intent_id) + end + + def fetch_default_payment_method + customer = Stripe::Customer.retrieve( + @customer_id, + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + ) + customer.invoice_settings&.default_payment_method + end + + def create_upfront_payment_intent(amount_due, currency, payment_method_id, intent_id) + payment_intent = Stripe::PaymentIntent.create( + payment_intent_params(amount_due, currency, payment_method_id, intent_id), + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + ) + + Rails.logger.info("Created payment intent: #{payment_intent.id} for amount: #{amount_due}") + payment_intent.id + end + + def payment_intent_params(amount_due, currency, payment_method_id, intent_id) + { + amount: amount_due, + currency: currency || 'usd', + customer: @customer_id, + payment_method: payment_method_id, + automatic_payment_methods: { + enabled: true, + allow_redirects: 'never' + }, + confirm: true, + off_session: true, + metadata: { billing_intent_id: intent_id } + } + end + + def fetch_billing_intent(intent_id) + StripeV2Client.request( + :get, + "/v2/billing/intents/#{intent_id}", + {}, + stripe_api_options + ) + end + + def commit_billing_intent(intent_id, payment_intent_id) + commit_params = payment_intent_id ? { payment_intent: payment_intent_id } : {} + + StripeV2Client.request( + :post, + "/v2/billing/intents/#{intent_id}/commit", + commit_params, + stripe_api_options + ) + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb index 2e0f94d19..d0417fe36 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -20,12 +20,12 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 def use_credit(feature: 'ai_captain', amount: 1, metadata: {}) return { success: true, credits_used: 0, remaining: total_credits } if amount <= 0 - stripe_result = report_usage_to_stripe(amount, feature, metadata) - return { success: false, message: "Usage reporting failed: #{stripe_result[:message]}" } unless stripe_result[:success] - with_locked_account do return { success: false, message: 'Insufficient credits' } unless sufficient_balance?(amount) + stripe_result = report_usage_to_stripe(amount, feature, metadata) + return { success: false, message: "Usage reporting failed: #{stripe_result[:message]}" } unless stripe_result[:success] + credit_type = deduct_credits(amount) log_credit_usage(amount, feature, credit_type, stripe_result[:event_id], metadata) build_credit_usage_result(amount, stripe_result[:event_id]) @@ -113,8 +113,8 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end def sufficient_balance?(amount) - current_balance = credit_balance - current_balance[:total] >= amount + # Use local balance (real-time) instead of Stripe balance (5-10 min delay) + total_credits >= amount end def deduct_credits(amount) @@ -143,12 +143,12 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end def build_credit_usage_result(amount, event_id) - final_balance = credit_balance + # Use local balance (already deducted) instead of fetching from Stripe { success: true, credits_used: amount, - remaining: final_balance[:total], - source: final_balance[:source], + remaining: total_credits, + source: 'local', stripe_event_id: event_id } end diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb index d001c9cc3..da2eb93cc 100644 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb @@ -87,25 +87,37 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing StripeV2Client.request( :post, '/v2/billing/service_actions', - { - lookup_key: lookup_key, - service_interval: 'month', - service_interval_count: 1, - type: 'credit_grant', - credit_grant: { - 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' } } - } - }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + service_action_params(lookup_key, credit_amount, cpu_id), + stripe_api_options ) 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 stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end + def create_rate_card(display_name:) StripeV2Client.request( :post, diff --git a/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb b/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb index 9805736e9..3f65eb9e5 100644 --- a/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb @@ -159,7 +159,7 @@ class Enterprise::Billing::V2::StripeCreditSyncService < Enterprise::Billing::V2 end def build_credit_grant_params(amount, type, metadata) - params = { + { customer: stripe_customer_id, name: "#{type.titleize} Credits - #{Time.current.strftime('%Y-%m-%d')}", amount: { type: 'monetary', monetary: { currency: 'usd', value: amount.to_i } }, @@ -172,8 +172,6 @@ class Enterprise::Billing::V2::StripeCreditSyncService < Enterprise::Billing::V2 credits: amount.to_s ) } - params[:expiry_config] = { type: 'end_of_service_period' } if type == 'monthly' - params end def create_stripe_grant(params) diff --git a/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb b/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb new file mode 100644 index 000000000..8c9244e37 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb @@ -0,0 +1,192 @@ +class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::V2::Concerns::PaymentIntentHandler + + def subscribe_to_pricing_plan(pricing_plan_id:, customer_id: nil) + @pricing_plan_id = pricing_plan_id + @customer_id = customer_id || stripe_customer_id + + validate_subscription_params + execute_subscription_flow + rescue Stripe::StripeError => e + { success: false, message: "Stripe API error: #{e.message}", error: e } + rescue StandardError => e + { success: false, message: "Subscription error: #{e.message}", error: e } + end + + private + + def validate_subscription_params + return { success: false, message: 'Customer ID required' } if @customer_id.blank? + return { success: false, message: 'Pricing Plan ID required' } if @pricing_plan_id.blank? + + nil + end + + def execute_subscription_flow + with_locked_account do + cadence = create_billing_cadence + return { success: false, message: 'Failed to create billing cadence' } unless cadence + + pricing_plan = pricing_plan_details + return { success: false, message: 'Failed to get pricing plan details' } unless pricing_plan + + intent = create_billing_intent(cadence.id, pricing_plan) + return { success: false, message: 'Failed to create billing intent' } unless intent + + reserve_and_commit_intent(intent.id) + update_account_subscription_info(pricing_plan) + + build_subscription_result(cadence.id, intent.id) + end + end + + def reserve_and_commit_intent(intent_id) + reserved_intent = reserve_intent(intent_id) + return { success: false, message: 'Failed to reserve intent' } unless reserved_intent + + committed_intent = commit_intent(intent_id) + return { success: false, message: 'Failed to commit intent' } unless committed_intent + + committed_intent + end + + def build_subscription_result(cadence_id, intent_id) + { + success: true, + customer_id: @customer_id, + pricing_plan_id: @pricing_plan_id, + cadence_id: cadence_id, + intent_id: intent_id, + status: 'subscribed' + } + end + + def stripe_customer_id + custom_attribute('stripe_customer_id') + end + + def create_billing_cadence + cadence_params = { + payer: { + type: 'customer', + customer: @customer_id + }, + billing_cycle: { + type: 'month', + interval_count: 1, + month: { + day_of_month: 1 + } + } + } + + StripeV2Client.request( + :post, + '/v2/billing/cadences', + cadence_params, + stripe_api_options + ) + end + + def pricing_plan_details + StripeV2Client.request( + :get, + "/v2/billing/pricing_plans/#{@pricing_plan_id}", + {}, + stripe_api_options + ) + end + + def create_billing_intent(cadence_id, pricing_plan) + plan_version = extract_plan_version(pricing_plan) + intent_params = build_intent_params(cadence_id, plan_version) + + StripeV2Client.request( + :post, + '/v2/billing/intents', + intent_params, + stripe_api_options + ) + end + + def extract_plan_version(pricing_plan) + pricing_plan['latest_version'] || pricing_plan['live_version'] || pricing_plan['version'] + end + + def build_intent_params(cadence_id, plan_version) + { + currency: 'usd', + cadence: cadence_id, + actions: [build_subscription_action(plan_version)] + } + end + + def build_subscription_action(plan_version) + { + type: 'subscribe', + subscribe: { + type: 'pricing_plan_subscription_details', + pricing_plan_subscription_details: { + pricing_plan: @pricing_plan_id, + pricing_plan_version: plan_version, + component_configurations: [] + } + } + } + end + + def reserve_intent(intent_id) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{intent_id}/reserve", + {}, + stripe_api_options + ) + end + + def commit_intent(intent_id) + ensure_payment_method + + intent = fetch_billing_intent(intent_id) + payment_intent_id = create_payment_if_needed(intent, intent_id) + + commit_billing_intent(intent_id, payment_intent_id) + end + + def ensure_payment_method + # Check if customer already has a payment method + customer = Stripe::Customer.retrieve( + @customer_id, + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + ) + + return if customer.invoice_settings&.default_payment_method.present? + + # In production, payment methods must be added via Checkout or SetupIntent + # This ensures proper customer authentication and PCI compliance + raise Stripe::StripeError, + 'Payment method required. Customer must add payment method via Stripe Checkout or SetupIntent before subscribing.' + end + + def update_account_subscription_info(pricing_plan) + update_custom_attributes( + 'stripe_billing_version' => 2, + 'stripe_customer_id' => @customer_id, + 'stripe_pricing_plan_id' => @pricing_plan_id, + 'plan_name' => extract_plan_name(pricing_plan), + 'subscription_status' => 'active' + ) + end + + def extract_plan_name(pricing_plan) + display_name = pricing_plan['display_name'] || pricing_plan[:display_name] + return 'Business' unless display_name + + # Extract plan name from display name like "Chatwoot Business - 2000 Credits" + display_name.split('-').first.strip.split.last || 'Business' + 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/lib/stripe_v2_client.rb b/enterprise/lib/stripe_v2_client.rb index 376ec8550..256489863 100644 --- a/enterprise/lib/stripe_v2_client.rb +++ b/enterprise/lib/stripe_v2_client.rb @@ -45,6 +45,12 @@ module StripeV2Client def parse_response(response) body = JSON.parse(response.body) + # Check for Stripe error responses + if body.is_a?(Hash) && body['error'] + error = body['error'] + raise Stripe::StripeError, "#{error['code']}: #{error['message']}" + end + # Convert to OpenStruct for dot notation access (mimicking Stripe SDK objects) case body when Hash 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 4b2c28672..c1a5274be 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,11 +6,23 @@ describe Enterprise::Billing::V2::CreditManagementService do before do allow(Enterprise::Billing::ReportUsageJob).to receive(:perform_later) + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('STRIPE_V2_METER_EVENT_NAME', anything).and_return('ai_prompts') + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('STRIPE_V2_METER_EVENT_NAME').and_return('ai_prompts') + allow(ENV).to receive(:[]).with('STRIPE_V2_METER_ID').and_return(nil) # Disable Stripe meter fetching + + # Stub Stripe credit grant creation + allow(Stripe::Billing::CreditGrant).to receive(:create).and_return( + OpenStruct.new(id: 'cg_test_123') + ) + account.update!( custom_attributes: (account.custom_attributes || {}).merge( 'stripe_billing_version' => 2, 'monthly_credits' => 100, - 'topup_credits' => 50 + 'topup_credits' => 50, + 'stripe_customer_id' => 'cus_test_123' ) ) end @@ -26,6 +38,13 @@ describe Enterprise::Billing::V2::CreditManagementService do end describe '#use_credit' do + before do + # Stub the Stripe meter event creation + allow(Stripe::Billing::MeterEvent).to receive(:create).and_return( + OpenStruct.new(identifier: 'test_event_123') + ) + end + context 'when sufficient monthly credits' do it 'uses monthly credits first' do result = service.use_credit(feature: 'ai_test', amount: 10) diff --git a/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb index 0bf0dc16c..b40972ffc 100644 --- a/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb @@ -38,6 +38,10 @@ describe Enterprise::Billing::V2::UsageAnalyticsService do metadata: {}, created_at: Time.current ) + + # Stub the Stripe API call to return nil (which will fallback to local data) + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('STRIPE_V2_METER_ID').and_return(nil) end it 'aggregates usage from credit transactions' do 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 b96b26440..9ff275476 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 @@ -20,22 +20,22 @@ describe Enterprise::Billing::V2::UsageReporterService do after { config.replace(original_config) } - it 'posts usage events to Stripe meters' do # rubocop:disable RSpec/MultipleExpectations - response = { 'id' => 'me_test_123' } - stripe_client = instance_double(Stripe::StripeClient) - allow(service).to receive(:stripe_client).and_return(stripe_client) - allow(stripe_client).to receive(:execute_request).and_return(response) + 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) + + # Stub ENV to return the configured meter event name from config + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('STRIPE_V2_METER_EVENT_NAME').and_return(nil) result = service.report(5, 'ai_test') expect(result).to include(success: true, reported_credits: 5) - expect(stripe_client).to have_received(:execute_request) do |method, path, **kw| - expect(method).to eq(:post) - expect(path).to eq('/v1/billing/meter_events') - expect(kw[:headers]).to include('Idempotency-Key') - expect(kw[:params][:event_name]).to eq('chat_prompts') - expect(kw[:params][:payload][:value]).to eq(5) - expect(kw[:params][:payload][:stripe_customer_id]).to eq('cus_test_123') + expect(Stripe::Billing::MeterEvent).to have_received(:create) do |params, options| + expect(params[:event_name]).to eq('chat_prompts') # Should use the config value + 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 end