fix rspec and customer subscription process

This commit is contained in:
Tanmay Deep Sharma
2025-10-10 13:03:27 +02:00
parent 51b4a8f9fc
commit d6f5c627eb
9 changed files with 340 additions and 40 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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