Merge branch 'stripe_v2/base' into stripe_v2/update_subscription

This commit is contained in:
Tanmay Deep Sharma
2025-11-17 10:23:15 +05:30
committed by GitHub
7 changed files with 242 additions and 140 deletions
@@ -34,7 +34,7 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2
)
if result[:success]
render json: { success: true, redirect_url: result[:redirect_url], session_id: result[:session_id] }
render json: { success: true, redirect_url: result[:redirect_url] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
@@ -44,6 +44,6 @@ class Enterprise::Webhooks::StripeController < ActionController::API
def v2_billing_event?(event_type)
Rails.logger.debug { "V2 billing event: #{event_type}" }
event_type.start_with?('v2.')
event_type.start_with?('v2.') if event_type.present?
end
end
@@ -105,22 +105,10 @@ class Enterprise::Billing::HandleStripeEventService
return credits.to_i if credits.present? && credits.to_i.positive?
end
# Fallback: extract from amount object
amount_data = extract_attribute(grant, :amount)
return 0 unless amount_data
0
end
def extract_attribute(object, attribute)
object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s]
end
def extract_amount_value(amount_data, unit_type)
unit = extract_attribute(amount_data, unit_type)
return 0 unless unit
value = extract_attribute(unit, :value)
value.to_i
end
end
@@ -205,8 +205,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
},
'conversation' => {},
'captain' => {
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit }
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
'monthly' => nil, 'topup' => nil },
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
'monthly' => 0, 'topup' => 0 }
},
'non_web_inboxes' => {}
}
@@ -2,24 +2,34 @@ require 'rails_helper'
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
describe 'POST /enterprise/webhooks/stripe' do
let(:params) { { content: 'hello' } }
let(:payload) { { type: 'customer.subscription.updated', content: 'hello' }.to_json }
let(:event_object) { instance_double(Stripe::Event, type: 'customer.subscription.updated') }
around do |example|
ENV['STRIPE_WEBHOOK_SECRET'] = 'test_secret'
ENV['STRIPE_WEBHOOK_SECRET_V2'] = 'test_secret_v2'
example.run
ENV.delete('STRIPE_WEBHOOK_SECRET')
ENV.delete('STRIPE_WEBHOOK_SECRET_V2')
end
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
handle_stripe = double
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
allow(Stripe::Webhook).to receive(:construct_event).and_return(event_object)
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
allow(handle_stripe).to receive(:perform)
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
expect(handle_stripe).to have_received(:perform).with(event: params)
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
expect(handle_stripe).to have_received(:perform).with(event: event_object)
end
it 'returns a bad request if the headers are missing' do
post '/enterprise/webhooks/stripe', params: params
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json' }
expect(response).to have_http_status(:bad_request)
end
it 'returns a bad request if the headers are invalid' do
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
allow(Stripe::Webhook).to receive(:construct_event).and_raise(Stripe::SignatureVerificationError.new('Invalid signature', 'sig'))
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
expect(response).to have_http_status(:bad_request)
end
end
@@ -1,139 +1,99 @@
require 'rails_helper'
describe Enterprise::Billing::CreateStripeCustomerService do
subject(:create_stripe_customer_service) { described_class }
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:stripe_customer_double) { instance_double(Stripe::Customer, id: 'cus_new') }
let(:subscriptions_list) { Stripe::ListObject.construct_from({ data: [] }) }
let(:hacker_plan_id) { 'price_hacker_random' }
describe '#perform' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).not_to have_received(:create)
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
before do
create(
:installation_config,
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{
stripe_customer_id: 'cus_random_number',
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
'name' => 'Hacker',
'limits' => {
'captain_responses_monthly' => 5,
'captain_responses_topup' => 0
}
}
]
)
it 'calls stripe methods to create a customer and updates the account' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: customer.id,
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id)
end
describe 'when checking for existing subscriptions' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
context 'when account has no stripe_customer_id' do
it 'creates a new subscription' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create)
expect(Stripe::Subscription).to have_received(:create)
end
end
context 'when account has stripe_customer_id' do
let(:stripe_customer_id) { 'cus_random_number' }
describe '#perform' do
context 'when account already has an active subscription' do
before do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
allow(Stripe::Subscription).to receive(:list)
.and_return(Stripe::ListObject.construct_from({ data: ['subscription'] }))
end
context 'when customer has active subscriptions' do
it 'returns without modifying the account or contacting Stripe' do
expect(Stripe::Customer).not_to receive(:create)
expect { service.perform }.not_to(change { account.reload.custom_attributes })
end
end
context 'when v2 billing configuration is missing' do
before do
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').destroy!
end
it 'raises an informative error' do
expect { service.perform }.to raise_error(
StandardError,
'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
)
end
end
context 'when account needs to be upgraded to v2 billing' do
context 'when stripe customer already exists' do
before do
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
allow(Stripe::Subscription).to receive(:create)
allow(Stripe::Customer).to receive(:create)
end
it 'does not create a new subscription' do
create_stripe_customer_service.new(account: account).perform
it 'does not create a new customer but updates custom attributes for v2 billing' do
service.perform
expect(Stripe::Subscription).not_to have_received(:create)
expect(Stripe::Subscription).to have_received(:list).with(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
expect(Stripe::Customer).not_to have_received(:create)
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_existing',
'stripe_pricing_plan_id' => hacker_plan_id,
'plan_name' => 'Hacker',
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
'stripe_billing_version' => 2
)
end
end
context 'when stripe customer does not exist' do
before do
allow(Stripe::Customer).to receive(:create).and_return(stripe_customer_double)
end
it 'creates a stripe customer and updates the account with v2 billing attributes' do
service.perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin.email })
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_new',
'stripe_pricing_plan_id' => hacker_plan_id,
'plan_name' => 'Hacker',
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
'stripe_billing_version' => 2
)
end
end
@@ -317,4 +317,146 @@ describe Enterprise::Billing::HandleStripeEventService do
end
end
end
describe 'credit grant handling' do
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
before do
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new)
.with(account: account).and_return(credit_service)
end
context 'when handling monthly credit grant' do
it 'adds credits from Stripe' do
allow(credit_service).to receive(:add_response_topup_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_123',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API (has complete amount structure)
api_grant_response = OpenStruct.new(
id: 'credgr_test_123',
customer: 'cus_123',
metadata: { 'credits' => '2000' },
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 2000)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_123')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_response_topup_credits).with(2000)
end
end
context 'when handling topup credit grant' do
it 'adds topup credits' do
allow(credit_service).to receive(:add_response_topup_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_456',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API (has complete amount structure)
api_grant_response = OpenStruct.new(
id: 'credgr_test_456',
customer: 'cus_123',
metadata: { 'credits' => '500' },
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 500)
),
expires_at: nil
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_456')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_response_topup_credits).with(500)
end
end
context 'when handling monetary type credit grant' do
it 'adds credits from monetary grant' do
allow(credit_service).to receive(:add_response_topup_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_monetary',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API with monetary amount
api_grant_response = OpenStruct.new(
id: 'credgr_test_monetary',
customer: 'cus_123',
metadata: { 'credits' => '1000' },
amount: OpenStruct.new(
type: 'monetary',
monetary: OpenStruct.new(
currency: 'usd',
value: 1000
)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_monetary')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_response_topup_credits).with(1000)
end
end
context 'when handling credit grant with zero amount' do
it 'does not call credit service' do
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_zero',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API with zero amount
api_grant_response = OpenStruct.new(
id: 'credgr_test_zero',
customer: 'cus_123',
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 0)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_zero')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
# Ensure we don't accidentally call these methods
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
end
end
end
end