From 2ab117e8eb0341748cbfbc6f5a0008ecbee91d0f Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 18 Feb 2026 22:10:06 -0800 Subject: [PATCH] feat(cloud-billing): cancel subscriptions at period end on deletion mark (#13580) ## How to reproduce In Chatwoot Cloud, mark an account for deletion from account settings while the account has an active Stripe subscription. Before this change, deletion marking did not explicitly mark subscriptions to stop renewing at period end. ## What changed This PR adds `Enterprise::Billing::CancelCloudSubscriptionsService` and calls it from the delete action path in `Enterprise::Api::V1::AccountsController`. The service lists only active Stripe subscriptions for the customer and sets `cancel_at_period_end: true` when needed. The account deletion schedule remains unchanged (existing static 7-day behavior), and Stripe deleted-event fallback behavior remains unchanged. ## How this was tested Added and updated specs: - `spec/enterprise/services/enterprise/billing/cancel_cloud_subscriptions_service_spec.rb` - `spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb` Executed: - `bundle exec rspec spec/enterprise/services/enterprise/billing/cancel_cloud_subscriptions_service_spec.rb` - `bundle exec rspec spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb:363` --- .../enterprise/api/v1/accounts_controller.rb | 8 +++ .../cancel_cloud_subscriptions_service.rb | 24 +++++++++ .../api/v1/accounts_controller_spec.rb | 24 ++++++++- ...cancel_cloud_subscriptions_service_spec.rb | 51 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 enterprise/app/services/enterprise/billing/cancel_cloud_subscriptions_service.rb create mode 100644 spec/enterprise/services/enterprise/billing/cancel_cloud_subscriptions_service_spec.rb 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