diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 0f829b973..d176db597 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -102,6 +102,8 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController reason = 'manual_deletion' if @account.mark_for_deletion(reason) + cancel_cloud_subscriptions_for_deletion + render json: { message: 'Account marked for deletion' }, status: :ok else render json: { message: @account.errors.full_messages.join(', ') }, status: :unprocessable_entity @@ -125,6 +127,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController render_redirect_url(session.url) end + def cancel_cloud_subscriptions_for_deletion + Enterprise::Billing::CancelCloudSubscriptionsService.new(account: @account).perform + rescue Stripe::StripeError => e + Rails.logger.warn("Failed to cancel cloud subscriptions for account #{@account.id}: #{e.class} - #{e.message}") + end + def render_redirect_url(redirect_url) render json: { redirect_url: redirect_url } end diff --git a/enterprise/app/services/enterprise/billing/cancel_cloud_subscriptions_service.rb b/enterprise/app/services/enterprise/billing/cancel_cloud_subscriptions_service.rb new file mode 100644 index 000000000..53032c8ca --- /dev/null +++ b/enterprise/app/services/enterprise/billing/cancel_cloud_subscriptions_service.rb @@ -0,0 +1,24 @@ +class Enterprise::Billing::CancelCloudSubscriptionsService + pattr_initialize [:account!] + + def perform + return if stripe_customer_id.blank? + return unless ChatwootApp.chatwoot_cloud? + + subscriptions.each do |subscription| + next if subscription.cancel_at_period_end + + Stripe::Subscription.update(subscription.id, cancel_at_period_end: true) + end + end + + private + + def subscriptions + Stripe::Subscription.list(customer: stripe_customer_id, status: 'active', limit: 100).data + end + + def stripe_customer_id + account.custom_attributes['stripe_customer_id'] + end +end 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 e79cbd237..9089b089a 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -357,10 +357,32 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do context 'when it is an admin' do before do # Create the installation config for cloud environment - InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') end it 'marks the account for deletion when action is delete' do + cancellation_service = instance_double(Enterprise::Billing::CancelCloudSubscriptionsService, perform: true) + allow(Enterprise::Billing::CancelCloudSubscriptionsService).to receive(:new).with(account: account) + .and_return(cancellation_service) + + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", + headers: admin.create_new_auth_token, + params: { action_type: 'delete' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_present + expect(account.custom_attributes['marked_for_deletion_reason']).to eq('manual_deletion') + expect(Enterprise::Billing::CancelCloudSubscriptionsService).to have_received(:new).with(account: account) + expect(cancellation_service).to have_received(:perform) + end + + it 'returns success even if stripe cancellation fails' do + cancellation_service = instance_double(Enterprise::Billing::CancelCloudSubscriptionsService) + allow(Enterprise::Billing::CancelCloudSubscriptionsService).to receive(:new).with(account: account) + .and_return(cancellation_service) + allow(cancellation_service).to receive(:perform).and_raise(Stripe::APIError.new('stripe unavailable')) + post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", headers: admin.create_new_auth_token, params: { action_type: 'delete' }, diff --git a/spec/enterprise/services/enterprise/billing/cancel_cloud_subscriptions_service_spec.rb b/spec/enterprise/services/enterprise/billing/cancel_cloud_subscriptions_service_spec.rb new file mode 100644 index 000000000..f9eab281b --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/cancel_cloud_subscriptions_service_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe Enterprise::Billing::CancelCloudSubscriptionsService do + subject(:service) { described_class.new(account: account) } + + let(:account) { create(:account, custom_attributes: custom_attributes) } + let(:custom_attributes) { { 'stripe_customer_id' => 'cus_123' } } + + describe '#perform' do + context 'when deployment is not cloud' do + it 'does not call stripe subscriptions api' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + allow(Stripe::Subscription).to receive(:list) + + service.perform + + expect(Stripe::Subscription).not_to have_received(:list) + end + end + + context 'when stripe customer id is missing' do + let(:custom_attributes) { {} } + + it 'does not call stripe subscriptions api' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + allow(Stripe::Subscription).to receive(:list) + + service.perform + + expect(Stripe::Subscription).not_to have_received(:list) + end + end + + context 'when account is cloud with active subscriptions' do + let(:subscription_response) { Struct.new(:data).new([sub_1, sub_2]) } + let(:sub_1) { instance_double(Stripe::Subscription, id: 'sub_1', cancel_at_period_end: false) } + let(:sub_2) { instance_double(Stripe::Subscription, id: 'sub_2', cancel_at_period_end: true) } + + it 'marks only active subscriptions that are not yet set to cancel at period end' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + allow(Stripe::Subscription).to receive(:list).and_return(subscription_response) + allow(Stripe::Subscription).to receive(:update) + + service.perform + + expect(Stripe::Subscription).to have_received(:list).with(customer: 'cus_123', status: 'active', limit: 100) + expect(Stripe::Subscription).to have_received(:update).with('sub_1', cancel_at_period_end: true).once + end + end + end +end