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`
This commit is contained in:
Sojan Jose
2026-02-18 22:10:06 -08:00
committed by GitHub
parent 594333a183
commit 2ab117e8eb
4 changed files with 106 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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' },

View File

@@ -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