Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user