diff --git a/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb b/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb index 6856f5523..7cc073b8e 100644 --- a/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb +++ b/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb @@ -15,7 +15,7 @@ class Enterprise::Billing::CreditSyncJob < ApplicationJob 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" + "custom_attributes->>'stripe_customer_id' IS NOT NULL AND (custom_attributes->>'stripe_billing_version')::integer = 2" ) synced_count = 0 failed_count = 0 diff --git a/enterprise/app/services/enterprise/billing/concerns/billing_intent_workflow.rb b/enterprise/app/services/enterprise/billing/concerns/billing_intent_workflow.rb index 0237e7bc6..6e87f0366 100644 --- a/enterprise/app/services/enterprise/billing/concerns/billing_intent_workflow.rb +++ b/enterprise/app/services/enterprise/billing/concerns/billing_intent_workflow.rb @@ -6,9 +6,9 @@ module Enterprise::Billing::Concerns::BillingIntentWorkflow # 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) + reserve_billing_intent(intent['id']) yield(intent) if block_given? - commit_billing_intent(intent) + commit_billing_intent(intent['id']) intent 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 fe27cf2d3..f48501efc 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 @@ -8,11 +8,6 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper @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_client.v2.billing.pricing_plan_subscriptions.retrieve(subscription_id) @@ -23,27 +18,27 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper 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_client.v2.billing.cadences.retrieve(cadence_id) end - # gem not available - using custom HTTP client def create_billing_intent(params) - stripe_v2_request(:post, '/v2/billing/intents', params) + response = Faraday.post('https://api.stripe.com/v2/billing/intents') do |req| + req.headers['Authorization'] = "Bearer #{ENV.fetch('STRIPE_SECRET_KEY', nil)}" + req.headers['Stripe-Version'] = default_stripe_version + req.headers['Content-Type'] = 'application/json' + req.body = params.to_json + end + + JSON.parse(response.body) end - def reserve_billing_intent(billing_intent) - stripe_client.v2.billing.intents.reserve(billing_intent.id) + def reserve_billing_intent(billing_intent_id) + stripe_client.v2.billing.intents.reserve(billing_intent_id) end - def commit_billing_intent(billing_intent) - stripe_client.v2.billing.intents.commit(billing_intent.id) + def commit_billing_intent(billing_intent_id) + stripe_client.v2.billing.intents.commit(billing_intent_id) end # Checkout Sessions (V1 API but used with V2 plans) @@ -56,14 +51,6 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper Stripe::Billing::CreditGrant.retrieve(grant_id) end - # API Options with support for custom versions - def stripe_api_options(custom_version = nil) - { - api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), - stripe_version: custom_version || default_stripe_version - } - end - def default_stripe_version '2025-10-29.preview' end diff --git a/enterprise/lib/stripe_v2_client.rb b/enterprise/lib/stripe_v2_client.rb deleted file mode 100644 index 33ed255a5..000000000 --- a/enterprise/lib/stripe_v2_client.rb +++ /dev/null @@ -1,111 +0,0 @@ -module StripeV2Client - class << self - def request(method, path, params = {}, options = {}) - api_key = options[:api_key] || ENV.fetch('STRIPE_SECRET_KEY', nil) - stripe_version = options[:stripe_version] || '2025-08-27.preview' - - uri = URI("https://api.stripe.com#{path}") - - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - - request = build_request(method, uri, params, path) - request['Authorization'] = "Bearer #{api_key}" - request['Stripe-Version'] = stripe_version - - response = http.request(request) - parse_response(response) - end - - private - - def build_request(method, uri, params, path) - case method - when :get - Net::HTTP::Get.new(uri) - when :post - req = Net::HTTP::Post.new(uri) - if v2_endpoint?(path) - # V2 endpoints use JSON - req.body = params.to_json unless params.empty? - req['Content-Type'] = 'application/json' - else - # V1 endpoints (including checkout sessions) use form data - # even with preview API versions - req.body = encode_nested_params(params) unless params.empty? - req['Content-Type'] = 'application/x-www-form-urlencoded' - end - req - when :delete - Net::HTTP::Delete.new(uri) - end - end - - def v2_endpoint?(path) - path.start_with?('/v2/') - end - - # Encode nested parameters for form submission - # Stripe expects nested params like: checkout_items[0][type]=value - def encode_nested_params(params, prefix = nil) - pairs = [] - params.each do |key, value| - full_key = prefix ? "#{prefix}[#{key}]" : key.to_s - pairs.concat(encode_param_value(full_key, value)) - end - pairs.join('&') - end - - def encode_param_value(key, value) - case value - when Hash - [encode_nested_params(value, key)] - when Array - value.each_with_index.map { |item, index| encode_nested_params(item, "#{key}[#{index}]") } - when true, false - ["#{CGI.escape(key)}=#{value}"] - when nil - [] - else - ["#{CGI.escape(key)}=#{CGI.escape(value.to_s)}"] - end - end - - def parse_response(response) - body = JSON.parse(response.body) - - # 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 - recursive_to_struct(body) - when Array - body.map { |item| recursive_to_struct(item) } - else - body - end - rescue JSON::ParserError - response.body - end - - def recursive_to_struct(hash) - return hash unless hash.is_a?(Hash) - - OpenStruct.new(hash.transform_values do |value| - case value - when Hash - recursive_to_struct(value) - when Array - value.map { |item| item.is_a?(Hash) ? recursive_to_struct(item) : item } - else - value - end - 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 802d56392..7b3a5c300 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 @@ -59,7 +59,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do 'plan_name' => 'Hacker', 'subscribed_quantity' => 2 ) - expect(service).to have_received(:enable_plan_specific_features).with('Startup') + expect(service).to have_received(:enable_plan_specific_features).with('Hacker') end it 'does not create new customer when customer already exists with V2' do 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 8a339af23..20abeaf63 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 @@ -16,24 +16,47 @@ describe Enterprise::Billing::V2::WebhookHandlerService do 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 + event_double = double + allow(event_double).to receive(:blank?).and_return(false) + allow(event_double).to receive(:type).and_return('v2.billing.pricing_plan_subscription.servicing_activated') + allow(event_double).to receive(:related_object).and_return(OpenStruct.new(id: 'bpps_sub_123')) + event_double end 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) + + # Mock the Stripe gem client methods + stripe_client_double = double + v2_double = double + billing_double = double + subscriptions_double = double + cadences_double = double + + allow(service).to receive(:stripe_client).and_return(stripe_client_double) + allow(stripe_client_double).to receive(:v2).and_return(v2_double) + allow(v2_double).to receive(:billing).and_return(billing_double) + allow(billing_double).to receive(:pricing_plan_subscriptions).and_return(subscriptions_double) + allow(billing_double).to receive(:cadences).and_return(cadences_double) + + allow(subscriptions_double).to receive(:retrieve).with('bpps_sub_123').and_return(subscription_response) + allow(cadences_double).to receive(:retrieve).with('cad_123').and_return(cadence_response) end it 'handles subscription activation' do + # Mock the subscription provisioning service + provisioning_service_double = instance_double( + Enterprise::Billing::V2::SubscriptionProvisioningService, + provision: { pricing_plan_id: pricing_plan_id, quantity: 1 } + ) + allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new) + .with(account: account) + .and_return(provisioning_service_double) + result = service.perform(event: event) + expect(provisioning_service_double).to have_received(:provision).with(subscription_id: 'bpps_sub_123') expect(result[:pricing_plan_id]).to eq(pricing_plan_id) end end