From 3055a7c6f086554bb26e0bdb3d5df9dae26ab434 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 17:41:17 +0530 Subject: [PATCH 01/13] feat: add cancel subscription in controller --- .../api/v1/accounts/concerns/billing_v2.rb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb index dc52958bd..d69fc06b3 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -41,7 +41,19 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2 end def cancel_subscription - render json: { success: true, message: 'Subscription cancelled.' } + service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account) + result = service.cancel_subscription + + if result[:success] + # Include account ID and updated attributes for frontend store update + @account.reload + render json: result.merge( + id: @account.id, + custom_attributes: @account.custom_attributes + ) + else + render json: { error: result[:message] }, status: :unprocessable_entity + end end def change_pricing_plan From f31666f8771a7c4c9b6b70c4b08edfc6549ab480 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 17:42:09 +0530 Subject: [PATCH 02/13] feat: add update subscription in controller --- .../api/v1/accounts/concerns/billing_v2.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb index dc52958bd..d90e9d644 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -45,7 +45,22 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2 end def change_pricing_plan - render json: { success: true, message: 'Pricing plan changed.' } + service = Enterprise::Billing::V2::ChangePlanService.new(account: @account) + result = service.change_plan( + new_pricing_plan_id: params[:pricing_plan_id], + quantity: params[:quantity]&.to_i + ) + + if result[:success] + # Include account ID and updated attributes for frontend store update + @account.reload + render json: result.merge( + id: @account.id, + custom_attributes: @account.custom_attributes + ) + else + render json: { error: result[:message] }, status: :unprocessable_entity + end end private From 7babcfe6226d612a0d6f3ddb00b5c01f8cb8f32d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 17:55:57 +0530 Subject: [PATCH 03/13] add updated rspecs --- .../api/v1/accounts_controller_spec.rb | 6 +- .../create_stripe_customer_service_spec.rb | 197 +++++++----------- .../handle_stripe_event_service_spec.rb | 142 +++++++++++++ 3 files changed, 226 insertions(+), 119 deletions(-) diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index 33941cee8..f56428d0a 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -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' => {} } 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 f5b0bbe86..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 @@ -5,137 +5,100 @@ describe Enterprise::Billing::CreateStripeCustomerService do let(:account) { create(:account) } let!(:admin1) { create(:user, account: account, role: :administrator) } - let(:admin2) { create(:user, account: account, role: :administrator) } - let(:subscriptions_list) { double } 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'] } - ] } - ) + context 'when V2 configs are missing' do + it 'raises a configuration error' do + expect do + create_stripe_customer_service.new(account: account).perform + end.to raise_error(StandardError, /V2 billing configuration is required/) + end 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 - ) + context 'with V2 billing' do + let(:cloud_plans_config) do + create(:installation_config, + name: 'CHATWOOT_CLOUD_PLANS', + value: [ + { + 'name' => 'Startup', + 'price_ids' => ['price_startup_123'], + 'default_quantity' => 2 + } + ]) + end - create_stripe_customer_service.new(account: account).perform + let(:hacker_plan_config) do + create(:installation_config, + name: 'STRIPE_HACKER_PLAN_ID', + value: 'bpp_hacker_123') + end - 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 }] }) + before do + # Setup configs + cloud_plans_config + hacker_plan_config + end - expect(account.reload.custom_attributes).to eq( - { - 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 - - 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 - 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 + it 'creates a stripe customer and sets up V2 billing' 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 + + # Mock the plan feature manager + service = create_stripe_customer_service.new(account: account) + allow(service).to receive(:enable_plan_specific_features) + + service.perform + + expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email }) + expect(account.reload.custom_attributes).to include( + 'stripe_customer_id' => 'cus_random_number', + 'stripe_billing_version' => 2, + 'stripe_pricing_plan_id' => 'bpp_hacker_123', + 'plan_name' => 'Hacker', + 'subscribed_quantity' => 2 ) + 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 + account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_v2' }) + + allow(Stripe::Customer).to receive(:create) + # Stub the subscription check to return no subscriptions + subscriptions_response = OpenStruct.new(data: []) + allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response) + + # Mock the plan feature manager + service = create_stripe_customer_service.new(account: account) + allow(service).to receive(:enable_plan_specific_features) + + service.perform + + expect(Stripe::Customer).not_to have_received(:create) + expect(account.reload.custom_attributes).to include( + 'stripe_customer_id' => 'cus_existing_v2', + 'stripe_billing_version' => 2, + 'stripe_pricing_plan_id' => 'bpp_hacker_123', + 'plan_name' => 'Hacker', + 'subscribed_quantity' => 2 + ) + end + + it 'skips setup when active subscription exists' do + account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_v2' }) + + allow(Stripe::Customer).to receive(:create) + # Stub the subscription check to return active subscription + subscription_data = OpenStruct.new(id: 'sub_123') + subscriptions_response = OpenStruct.new(data: [subscription_data]) + allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response) 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' } - - before do - account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id }) - end - - context 'when customer has active subscriptions' do - before do - 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) - end - - it 'does not create a new subscription' do - create_stripe_customer_service.new(account: account).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 - } - ) - end + expect(Stripe::Customer).not_to have_received(:create) + expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_v2') end end end diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index 67ccd168b..51221ac5c 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -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 From d230bb68d0fde2ceeca010349badaa3b2fbd574c Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 19:06:24 +0530 Subject: [PATCH 04/13] add updated rspecs --- .../create_stripe_customer_service_spec.rb | 161 +++++++++--------- 1 file changed, 79 insertions(+), 82 deletions(-) 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 7b3a5c300..8987a15e8 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 @@ -1,104 +1,101 @@ 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!(: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' } + + before do + create( + :installation_config, + name: 'CHATWOOT_CLOUD_PLANS', + value: [ + { + 'name' => 'Hacker', + 'limits' => { + 'captain_responses_monthly' => 5, + 'captain_responses_topup' => 0 + } + } + ] + ) + + create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id) + end describe '#perform' do - context 'when V2 configs are missing' do - it 'raises a configuration error' do - expect do - create_stripe_customer_service.new(account: account).perform - end.to raise_error(StandardError, /V2 billing configuration is required/) + context 'when account already has an active subscription' do + before do + account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' }) + allow(Stripe::Subscription).to receive(:list) + .and_return(Stripe::ListObject.construct_from({ data: ['subscription'] })) + end + + 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 'with V2 billing' do - let(:cloud_plans_config) do - create(:installation_config, - name: 'CHATWOOT_CLOUD_PLANS', - value: [ - { - 'name' => 'Startup', - 'price_ids' => ['price_startup_123'], - 'default_quantity' => 2 - } - ]) - end - - let(:hacker_plan_config) do - create(:installation_config, - name: 'STRIPE_HACKER_PLAN_ID', - value: 'bpp_hacker_123') - end - + context 'when v2 billing configuration is missing' do before do - # Setup configs - cloud_plans_config - hacker_plan_config + InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').destroy! end - it 'creates a stripe customer and sets up V2 billing' do - customer = double - allow(Stripe::Customer).to receive(:create).and_return(customer) - allow(customer).to receive(:id).and_return('cus_random_number') - - # Mock the plan feature manager - service = create_stripe_customer_service.new(account: account) - allow(service).to receive(:enable_plan_specific_features) - - service.perform - - expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email }) - expect(account.reload.custom_attributes).to include( - 'stripe_customer_id' => 'cus_random_number', - 'stripe_billing_version' => 2, - 'stripe_pricing_plan_id' => 'bpp_hacker_123', - 'plan_name' => 'Hacker', - 'subscribed_quantity' => 2 - ) - 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 - account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_v2' }) - - allow(Stripe::Customer).to receive(:create) - # Stub the subscription check to return no subscriptions - subscriptions_response = OpenStruct.new(data: []) - allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response) - - # Mock the plan feature manager - service = create_stripe_customer_service.new(account: account) - allow(service).to receive(:enable_plan_specific_features) - - service.perform - - expect(Stripe::Customer).not_to have_received(:create) - expect(account.reload.custom_attributes).to include( - 'stripe_customer_id' => 'cus_existing_v2', - 'stripe_billing_version' => 2, - 'stripe_pricing_plan_id' => 'bpp_hacker_123', - 'plan_name' => 'Hacker', - 'subscribed_quantity' => 2 + 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 - it 'skips setup when active subscription exists' do - account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_v2' }) + 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(Stripe::Customer).to receive(:create) + end - allow(Stripe::Customer).to receive(:create) - # Stub the subscription check to return active subscription - subscription_data = OpenStruct.new(id: 'sub_123') - subscriptions_response = OpenStruct.new(data: [subscription_data]) - allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response) + it 'does not create a new customer but updates custom attributes for v2 billing' do + service.perform - create_stripe_customer_service.new(account: account).perform + expect(Stripe::Customer).not_to have_received(:create) - expect(Stripe::Customer).not_to have_received(:create) - expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_v2') + 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 end end From eba12567e9f438113cbba1aa7f426397c82f3ea3 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 19:15:20 +0530 Subject: [PATCH 05/13] cursor review commit fixes --- .../api/v1/accounts/concerns/billing_v2.rb | 2 +- .../enterprise/webhooks/stripe_controller.rb | 2 +- .../billing/handle_stripe_event_service.rb | 12 ------------ 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb index dc52958bd..c20c5f54c 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb index 7c248659d..0cc523889 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 5343e9ef0..a1baa7b0f 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -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 From 418d9cce27fb225110bf3458d0439d8cc5969505 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 20:44:10 +0530 Subject: [PATCH 06/13] update stripe controller rspec --- .../webooks/stripe_controller_spec.rb | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb b/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb index bbcddeb29..96d868610 100644 --- a/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb @@ -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 From 6abecdfd435cf77bc4a4002309878d852ae05335 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Mon, 17 Nov 2025 11:15:21 +0530 Subject: [PATCH 07/13] update the logic to get the number of credits for a monthly plan --- .../billing/concerns/plan_provisioning_helper.rb | 10 ---------- .../enterprise/billing/handle_stripe_event_service.rb | 11 ++++++++++- .../services/enterprise/billing/v2/plan_catalog.rb | 5 ----- .../billing/v2/subscription_provisioning_service.rb | 4 +++- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb index cb5447c48..d2cb238ad 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb @@ -4,8 +4,6 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper private def provision_new_plan(new_pricing_plan_id) - sync_plan_credits(new_pricing_plan_id) - plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id) return unless plan_definition @@ -13,14 +11,6 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper enable_plan_specific_features(plan_name) if plan_name.present? end - def sync_plan_credits(pricing_plan_id) - plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id) - - Enterprise::Billing::V2::CreditManagementService - .new(account: account) - .sync_monthly_response_credits(plan_credits.to_i) - end - def extract_plan_name(plan_definition) plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) } end diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index a1baa7b0f..fac927eef 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -105,7 +105,16 @@ class Enterprise::Billing::HandleStripeEventService return credits.to_i if credits.present? && credits.to_i.positive? end - 0 + amount = extract_attribute(grant, :amount) + return 0 if amount.blank? + + custom_pricing_unit = extract_attribute(amount, :custom_pricing_unit) + return 0 if custom_pricing_unit.blank? + + value = extract_attribute(custom_pricing_unit, :value) + return 0 if value.blank? + + value.to_i end def extract_attribute(object, attribute) diff --git a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb index c0d9583df..12655104e 100644 --- a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -53,11 +53,6 @@ module Enterprise::Billing::V2::PlanCatalog nil end - def monthly_credits_for(plan_id) - definition = definition_for(plan_id) - definition ? definition[:monthly_credits] : nil - end - def plan_id_for(definition) InstallationConfig.find_by(name: definition[:config_key])&.value end diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb index 6b0018e0d..9bdc106ec 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -63,7 +63,9 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil update_custom_attributes(attributes) # Sync credits for Hacker plan (0 credits) - sync_plan_credits(pricing_plan_id) + Enterprise::Billing::V2::CreditManagementService + .new(account: account) + .sync_monthly_response_credits(0) # Disable all premium features and save disable_all_premium_features From 689525ce853b3f84548aa5329058a6c0dfeb0cd5 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 17 Nov 2025 11:57:27 +0530 Subject: [PATCH 08/13] fix credit addition for topup and monthly --- app/helpers/billing_helper.rb | 5 ++++- .../billing/handle_stripe_event_service.rb | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86..65c0b55b5 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -8,7 +8,10 @@ module BillingHelper # Return false if not plans are configured, so that no checks are enforced return false if default_plan.blank? - account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name'] + # Handle both string and hash formats for default_plan + default_plan_name = default_plan.is_a?(Hash) ? default_plan['name'] : default_plan + + account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan_name end def conversations_this_month(account) diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index fac927eef..4d2978b07 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -89,14 +89,29 @@ class Enterprise::Billing::HandleStripeEventService amount = extract_credit_amount(grant) return if amount.zero? + grant_type = extract_grant_type(grant) service = Enterprise::Billing::V2::CreditManagementService.new(account: account) - service.add_response_topup_credits(amount) + if grant_type == 'monetary' + service.add_response_topup_credits(amount) + else + service.sync_monthly_response_credits(amount) + end end def extract_credit_grant_id(grant_object) grant_object.respond_to?(:id) ? grant_object.id : grant_object['id'] end + def extract_grant_type(grant) + amount_object = extract_attribute(grant, :amount) + return 'monetary' if amount_object.blank? + + type = extract_attribute(amount_object, :type) + return 'monetary' if type.blank? + + type + end + def extract_credit_amount(grant) # First, try to get credits from metadata metadata = extract_attribute(grant, :metadata) @@ -104,7 +119,6 @@ class Enterprise::Billing::HandleStripeEventService credits = extract_attribute(metadata, :credits) return credits.to_i if credits.present? && credits.to_i.positive? end - amount = extract_attribute(grant, :amount) return 0 if amount.blank? From 01d17674e6435326447f7da04a8935da5ec108e0 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 19 Nov 2025 19:39:40 +0530 Subject: [PATCH 09/13] fix comments --- config/locales/en.yml | 11 +++ config/routes.rb | 16 ++-- .../api/v1/accounts/concerns/billing_v2.rb | 62 -------------- .../enterprise/api/v1/accounts_controller.rb | 1 - .../enterprise/api/v2/billing_controller.rb | 80 +++++++++++++++++++ .../enterprise/webhooks/stripe_controller.rb | 6 +- .../billing/concerns/plan_feature_manager.rb | 5 +- .../concerns/plan_provisioning_helper.rb | 2 +- .../concerns/stripe_v2_client_helper.rb | 4 +- .../billing/create_stripe_customer_service.rb | 2 +- .../enterprise/billing/v2/base_service.rb | 16 +--- .../billing/v2/checkout_session_service.rb | 6 +- .../billing/v2/credit_management_service.rb | 2 +- .../enterprise/billing/v2/plan_catalog.rb | 2 +- .../v2/subscription_provisioning_service.rb | 2 + .../billing/v2/webhook_handler_service.rb | 11 +-- 16 files changed, 127 insertions(+), 101 deletions(-) delete mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb create mode 100644 enterprise/app/controllers/enterprise/api/v2/billing_controller.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index d0f256146..ea7d7c678 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -119,6 +119,12 @@ en: invalid_token: Invalid or expired MFA token invalid_credentials: Invalid credentials or verification code feature_unavailable: MFA feature is not available. Please configure encryption keys. + enterprise: + billing: + topup_amount_invalid: Topup amount must be greater than 0 + stripe_customer_required: Customer ID required. Please create a Stripe customer first. + lookup_key_not_found: Lookup key not found for pricing plan %{pricing_plan_id} + v2_configuration_required: V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID. profile: mfa: enabled: MFA enabled successfully @@ -435,3 +441,8 @@ en: subject: 'Finish setting up %{custom_domain}' ssl_status: custom_domain_not_configured: 'Custom domain is not configured' + enterprise: + billing: + topup_successful: Topup successful + subscription_cancelled: Subscription cancelled + pricing_plan_changed: Pricing plan changed diff --git a/config/routes.rb b/config/routes.rb index d886d004a..4d9ef8221 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -431,12 +431,18 @@ Rails.application.routes.draw do post :subscription get :limits post :toggle_deletion - # V2 Billing endpoints + end + end + end + + namespace :v2 do + resources :accounts, only: [] do + resource :billing, only: [] do get :credit_grants - get :v2_pricing_plans - get :v2_topup_options - post :v2_topup - post :v2_subscribe + get :pricing_plans + get :topup_options + post :topup + post :subscribe post :cancel_subscription post :change_pricing_plan end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb deleted file mode 100644 index c20c5f54c..000000000 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ /dev/null @@ -1,62 +0,0 @@ -module Enterprise::Api::V1::Accounts::Concerns::BillingV2 - extend ActiveSupport::Concern - - included do - before_action :validate_topup_amount, only: [:v2_topup] - end - - def credit_grants - service = Enterprise::Billing::V2::CreditManagementService.new(account: @account) - grants = service.fetch_credit_grants - - render json: { credit_grants: grants } - end - - def v2_pricing_plans - plans = Enterprise::Billing::V2::PlanCatalog.plans - render json: { pricing_plans: plans } - end - - def v2_topup_options - options = Enterprise::Billing::V2::TopupCatalog.options - render json: { topup_options: options } - end - - def v2_topup - render json: { success: true, message: 'Topup successful.' } - end - - def v2_subscribe - service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account) - result = service.create_subscription_checkout( - pricing_plan_id: params[:pricing_plan_id], - quantity: subscription_quantity - ) - - if result[:success] - render json: { success: true, redirect_url: result[:redirect_url] } - else - render json: { error: result[:message] }, status: :unprocessable_entity - end - end - - def cancel_subscription - render json: { success: true, message: 'Subscription cancelled.' } - end - - def change_pricing_plan - render json: { success: true, message: 'Pricing plan changed.' } - end - - private - - def subscription_quantity - [params[:quantity].to_i, 1].max - end - - def validate_topup_amount - return if params[:credits].to_i.positive? - - render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity - end -end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 7fed4dfcf..77791cf4d 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -1,6 +1,5 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper - include Enterprise::Api::V1::Accounts::Concerns::BillingV2 before_action :fetch_account before_action :check_authorization diff --git a/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb b/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb new file mode 100644 index 000000000..892b6ea10 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb @@ -0,0 +1,80 @@ +class Enterprise::Api::V2::BillingController < Api::BaseController + before_action :fetch_account + before_action :check_authorization + before_action :validate_topup_amount, only: [:topup] + + rescue_from StandardError, with: :render_error + rescue_from NotImplementedError, with: :render_not_implemented + + def credit_grants + service = Enterprise::Billing::V2::CreditManagementService.new(account: @account) + grants = service.fetch_credit_grants + + render json: { credit_grants: grants } + end + + def pricing_plans + plans = Enterprise::Billing::V2::PlanCatalog.plans + render json: { pricing_plans: plans } + end + + def topup_options + options = Enterprise::Billing::V2::TopupCatalog.options + render json: { topup_options: options } + end + + def topup + raise NotImplementedError, 'Topup functionality not yet implemented' + end + + def subscribe + service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account) + redirect_url = service.create_subscription_checkout( + pricing_plan_id: params[:pricing_plan_id], + quantity: subscription_quantity + ) + + render json: { redirect_url: redirect_url } + end + + def cancel_subscription + raise NotImplementedError, 'Cancel subscription functionality not yet implemented' + end + + def change_pricing_plan + raise NotImplementedError, 'Change pricing plan functionality not yet implemented' + end + + private + + def fetch_account + @account = current_user.accounts.find(params[:account_id]) + @current_account_user = @account.account_users.find_by(user_id: current_user.id) + end + + def subscription_quantity + [params[:quantity].to_i, 1].max + end + + def validate_topup_amount + return if params[:credits].to_i.positive? + + render json: { error: I18n.t('errors.enterprise.billing.topup_amount_invalid') }, status: :unprocessable_entity + end + + def pundit_user + { + user: current_user, + account: @account, + account_user: @current_account_user + } + end + + def render_error(exception) + render json: { error: exception.message }, status: :unprocessable_entity + end + + def render_not_implemented(exception) + render json: { error: exception.message }, status: :not_implemented + end +end diff --git a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb index 0cc523889..f3bcf7f4d 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -35,6 +35,8 @@ class Enterprise::Webhooks::StripeController < ActionController::API parsed_payload = JSON.parse(payload) event_type = parsed_payload['type'] + return ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) if event_type.blank? + if v2_billing_event?(event_type) ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil) else @@ -43,7 +45,9 @@ class Enterprise::Webhooks::StripeController < ActionController::API end def v2_billing_event?(event_type) + return false if event_type.blank? + Rails.logger.debug { "V2 billing event: #{event_type}" } - event_type.start_with?('v2.') if event_type.present? + event_type.start_with?('v2.') end end diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb index 5a5e955fe..a7e971730 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb @@ -46,10 +46,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager end def enable_features_for_current_plan(plan_name) - # First disable all premium features to handle downgrades disable_all_premium_features - - # Then enable features based on the current plan enable_plan_specific_features(plan_name) end @@ -58,7 +55,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager # Enable features based on plan hierarchy case plan_name - when 'Startup', 'Startups' + when 'Startups' # Startup plan gets the basic features account.enable_features(*STARTUP_PLAN_FEATURES) when 'Business' diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb index d2cb238ad..68c6b5850 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb @@ -12,7 +12,7 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper end def extract_plan_name(plan_definition) - plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) } + plan_definition[:display_name].split.find { |word| %w[Startups Business Enterprise].include?(word) } end def update_account_plan(new_pricing_plan_id, quantity, next_billing_date) 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 f48501efc..2673ff31b 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 @@ -60,6 +60,8 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper end def extract_attribute(object, key) - object.respond_to?(key) ? object.public_send(key) : object[key.to_s] + return object.public_send(key) if object.respond_to?(key) + + object[key.to_s] end end diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb index 05f8048cf..26407ffc3 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -35,7 +35,7 @@ class Enterprise::Billing::CreateStripeCustomerService end def raise_config_error - raise StandardError, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.' + raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required') end def existing_subscription? diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb index 7ab1ccd9c..e3792915e 100644 --- a/enterprise/app/services/enterprise/billing/v2/base_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -28,7 +28,7 @@ class Enterprise::Billing::V2::BaseService # Update response credits (monthly/topup with auto-calculation of total) def update_response_credits(monthly: nil, topup: nil) # Calculate and update total in limits hash ONLY - return unless monthly || topup + return if monthly.nil? && topup.nil? new_monthly = monthly || response_monthly_credits new_topup = topup || response_topup_credits @@ -44,23 +44,13 @@ class Enterprise::Billing::V2::BaseService def update_limits(updates) return if updates.blank? - current_limits = account.limits.present? ? account.limits.deep_dup : {} - updates.each do |key, value| - current_limits[key.to_s] = value - end - - account.update!(limits: current_limits) + account.update!(limits: (account.limits || {}).merge(updates.transform_keys(&:to_s))) end def update_custom_attributes(updates) return if updates.blank? - current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {} - updates.each do |key, value| - current_attributes[key.to_s] = value - end - - account.update!(custom_attributes: current_attributes) + account.update!(custom_attributes: (account.custom_attributes || {}).merge(updates.transform_keys(&:to_s))) end def custom_attribute(key) diff --git a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb index 4e21bf0e5..6b5766927 100644 --- a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb @@ -13,13 +13,13 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: validate_params store_pending_subscription_quantity session = create_checkout_session(checkout_session_params) - { success: true, redirect_url: session.url } + session.url end private def validate_params - raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank? + raise StandardError, I18n.t('errors.enterprise.billing.stripe_customer_required') if stripe_customer_id.blank? end def store_pending_subscription_quantity @@ -50,7 +50,7 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: def build_checkout_items lookup_key = extract_license_lookup_key - raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key + raise StandardError, I18n.t('errors.enterprise.billing.lookup_key_not_found', pricing_plan_id: @pricing_plan_id) unless lookup_key [ { diff --git a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb index e4d9a8b39..6ebc5c7ef 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -32,7 +32,7 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end grants.reject { |grant| grant[:credits].zero? } rescue Stripe::StripeError => e - Rails.logger.error("Failed to fetch credit grants: #{e.message}") + ChatwootExceptionTracker.new(e, account: account).capture_exception [] end diff --git a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb index 12655104e..c45a79f38 100644 --- a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -13,7 +13,7 @@ module Enterprise::Billing::V2::PlanCatalog }, { key: :startup, - display_name: 'Chatwoot Startup', + display_name: 'Chatwoot Startups', base_fee: 19.0, monthly_credits: 300, config_key: 'STRIPE_STARTUP_PLAN_ID', diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb index 9bdc106ec..17ba5c5ed 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -49,6 +49,8 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil def cancel_subscription hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID') + return if hacker_plan_config.nil? + pricing_plan_id = hacker_plan_config.value # Update subscription status and plan details diff --git a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb index ee569b217..bd227df2e 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -3,9 +3,8 @@ class Enterprise::Billing::V2::WebhookHandlerService def perform(event:) @event = event - return { success: false, message: 'Event is required' } if @event.blank? - - return { success: false, message: 'Account not found' } if account.blank? + raise StandardError, 'Event is required' if @event.blank? + raise StandardError, 'Account not found' if account.blank? case @event.type when 'v2.billing.pricing_plan_subscription.servicing_activated' @@ -14,12 +13,10 @@ class Enterprise::Billing::V2::WebhookHandlerService when 'v2.billing.cadence.billed' Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}" refresh_account_subscription_details(@event.related_object.id) - else - { success: true } end rescue StandardError => e - Rails.logger.error "Error processing V2 webhook: #{e.message}" - { success: false, error: e.message } + ChatwootExceptionTracker.new(e, account: account).capture_exception + raise end private From 69b818896a30863f9af6a50646591a74ac1f4dc8 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 19 Nov 2025 20:01:49 +0530 Subject: [PATCH 10/13] update plan catalog display_name --- .../concerns/plan_provisioning_helper.rb | 21 +------------------ .../enterprise/billing/v2/plan_catalog.rb | 8 +++---- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb index 68c6b5850..18ad1f1b6 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb @@ -7,26 +7,7 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id) return unless plan_definition - plan_name = extract_plan_name(plan_definition) + plan_name = plan_definition[:display_name] enable_plan_specific_features(plan_name) if plan_name.present? end - - def extract_plan_name(plan_definition) - plan_definition[:display_name].split.find { |word| %w[Startups Business Enterprise].include?(word) } - end - - def update_account_plan(new_pricing_plan_id, quantity, next_billing_date) - attributes = { - 'stripe_pricing_plan_id' => new_pricing_plan_id, - 'pending_stripe_pricing_plan_id' => nil, - 'pending_subscription_quantity' => nil, - 'subscribed_quantity' => quantity, - 'next_billing_date' => next_billing_date - } - - plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id) - attributes['plan_name'] = plan_definition[:display_name] if plan_definition - - update_custom_attributes(attributes) - end end diff --git a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb index c45a79f38..8d9d3b0b4 100644 --- a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -5,7 +5,7 @@ module Enterprise::Billing::V2::PlanCatalog PLAN_DEFINITIONS = [ { key: :free, - display_name: 'Chatwoot Hacker', + display_name: 'Hacker', base_fee: 0.0, monthly_credits: 0, config_key: 'STRIPE_HACKER_PLAN_ID', @@ -13,7 +13,7 @@ module Enterprise::Billing::V2::PlanCatalog }, { key: :startup, - display_name: 'Chatwoot Startups', + display_name: 'Startups', base_fee: 19.0, monthly_credits: 300, config_key: 'STRIPE_STARTUP_PLAN_ID', @@ -21,7 +21,7 @@ module Enterprise::Billing::V2::PlanCatalog }, { key: :business, - display_name: 'Chatwoot Business', + display_name: 'Business', base_fee: 39.0, monthly_credits: 500, config_key: 'STRIPE_BUSINESS_PLAN_ID', @@ -29,7 +29,7 @@ module Enterprise::Billing::V2::PlanCatalog }, { key: :enterprise, - display_name: 'Chatwoot Enterprise', + display_name: 'Enterprise', base_fee: 99.0, monthly_credits: 800, config_key: 'STRIPE_ENTERPRISE_PLAN_ID', From 7d2307d34387f803fcfe4b6285c4cc4671548d1d Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Mon, 24 Nov 2025 19:15:01 +0530 Subject: [PATCH 11/13] fix spec handle stripe event service spec --- .../billing/handle_stripe_event_service_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index 51221ac5c..cc4a5fc47 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -328,7 +328,7 @@ describe Enterprise::Billing::HandleStripeEventService do context 'when handling monthly credit grant' do it 'adds credits from Stripe' do - allow(credit_service).to receive(:add_response_topup_credits) + allow(credit_service).to receive(:sync_monthly_response_credits) # Webhook event object (minimal, just has ID) grant_event_object = OpenStruct.new( @@ -355,13 +355,13 @@ describe Enterprise::Billing::HandleStripeEventService do stripe_event_service.new.perform(event: event) - expect(credit_service).to have_received(:add_response_topup_credits).with(2000) + expect(credit_service).to have_received(:sync_monthly_response_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) + allow(credit_service).to receive(:sync_monthly_response_credits) # Webhook event object (minimal, just has ID) grant_event_object = OpenStruct.new( @@ -388,7 +388,7 @@ describe Enterprise::Billing::HandleStripeEventService do stripe_event_service.new.perform(event: event) - expect(credit_service).to have_received(:add_response_topup_credits).with(500) + expect(credit_service).to have_received(:sync_monthly_response_credits).with(500) end end From 91bfc47f01a3ecda435c58c8bd1c5c17215eb88f Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Mon, 24 Nov 2025 19:18:02 +0530 Subject: [PATCH 12/13] add change pricing plan view --- .../enterprise/api/v2/billing_controller.rb | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb b/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb index 892b6ea10..657ac4ca8 100644 --- a/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb @@ -42,7 +42,22 @@ class Enterprise::Api::V2::BillingController < Api::BaseController end def change_pricing_plan - raise NotImplementedError, 'Change pricing plan functionality not yet implemented' + service = Enterprise::Billing::V2::ChangePlanService.new(account: @account) + result = service.change_plan( + new_pricing_plan_id: params[:pricing_plan_id], + quantity: params[:quantity]&.to_i + ) + + if result[:success] + # Include account ID and updated attributes for frontend store update + @account.reload + render json: result.merge( + id: @account.id, + custom_attributes: @account.custom_attributes + ) + else + render json: { error: result[:message] }, status: :unprocessable_entity + end end private From afa7f2d325ed522f34db6aaa66642155e6c426e1 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Mon, 24 Nov 2025 19:38:38 +0530 Subject: [PATCH 13/13] add default payment method for change of subscription --- .../billing/v2/change_plan_service.rb | 12 ++++++ .../billing/v2/invoice_payment_service.rb | 42 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb index 30ba26055..d30c204b5 100644 --- a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb @@ -139,4 +139,16 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base } ) end + + def update_account_plan(plan_id, quantity, next_billing_date) + plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id) + plan_name = plan_definition&.dig(:display_name) + + update_custom_attributes({ + 'pricing_plan_id' => plan_id, + 'subscribed_quantity' => quantity, + 'plan_name' => plan_name, + 'next_billing_date' => next_billing_date + }) + end end diff --git a/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb b/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb index 8b3ceaa8c..0cc6ca950 100644 --- a/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb @@ -9,6 +9,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: # @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: } # Validate that customer has a default payment method + # If no default is set but payment methods exist, automatically set the first one as default # @return [Hash, nil] Returns error hash if validation fails, nil if success def validate_payment_method return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank? @@ -16,10 +17,9 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: customer = Stripe::Customer.retrieve(stripe_customer_id) if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil? - return { - success: false, - message: 'No default payment method found. Please add a default payment method before making a purchase.' - } + # No default payment method found - try to set one automatically + ensure_default_payment_method_result = ensure_default_payment_method(customer) + return ensure_default_payment_method_result unless ensure_default_payment_method_result.nil? end nil @@ -28,6 +28,40 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: { success: false, message: "Error validating payment method: #{e.message}" } end + # Ensure a default payment method is set for the customer + # If payment methods exist but none is default, set the first one as default + # @param customer [Stripe::Customer] The Stripe customer object + # @return [Hash, nil] Returns error hash if no payment methods exist, nil if default is set successfully + def ensure_default_payment_method(customer) + payment_methods = fetch_customer_payment_methods(customer.id) + return no_payment_methods_error if payment_methods.data.empty? + + set_first_payment_method_as_default(customer.id, payment_methods.data.first) + nil + rescue Stripe::StripeError => e + Rails.logger.error("Failed to set default payment method: #{e.message}") + { success: false, message: "Error setting default payment method: #{e.message}" } + end + + def fetch_customer_payment_methods(customer_id) + Stripe::PaymentMethod.list(customer: customer_id, limit: 100) + end + + def no_payment_methods_error + { + success: false, + message: 'No payment methods found. Please add a payment method before making a purchase.' + } + end + + def set_first_payment_method_as_default(customer_id, payment_method) + Stripe::Customer.update( + customer_id, + invoice_settings: { default_payment_method: payment_method.id } + ) + Rails.logger.info("Automatically set payment method #{payment_method.id} as default for customer #{customer_id}") + end + # Create invoice with line items and charge immediately # # @param line_items [Array] Line items: [{ amount: (cents), description:, metadata: }]