mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 02:32:29 +00:00
fix rspec and customer subscription process
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user