fix: settle captain credits on subscription cancellation (#14089)

## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6875/captain-credits-3-bugs-in-stripe-subscription-lifecycle-cancel-ratchet

## Description

Fixes Captain credit settlement on subscription cancellation. Previously
`limits['captain_responses']` and `captain_responses_usage` were left in
their pre-cancellation state, which caused incorrect credit totals when
a customer re-subscribed. Cancellation now settles the monthly allotment
(preserving any remaining topup) and resets the usage counter.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

1. Set up an account subscribed to a paid plan (e.g. Startups) so
`limits['captain_responses']` reflects the plan allotment.
2. Fire `customer.subscription.deleted` for that account's Stripe
customer. Confirm the limits.
3. Fire `customer.subscription.updated` re-subscribing to the paid plan.
Confirm the limits.
4. Repeat cancel → re-subscribe several times;


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Tanmay Deep Sharma
2026-05-08 12:18:53 +05:30
committed by GitHub
parent 10597863d7
commit e6b8f48b3b
3 changed files with 30 additions and 17 deletions
@@ -4,15 +4,17 @@ class Enterprise::Billing::CreateStripeCustomerService
DEFAULT_QUANTITY = 2
def perform
return if existing_subscription?
active_sub = active_subscription
return false if active_sub && !default_plan_subscription?(active_sub)
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
subscription = active_sub || Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
custom_attributes = build_custom_attributes(customer_id, subscription)
custom_attributes.except!('is_creating_customer')
account.update!(custom_attributes: custom_attributes)
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
true
end
private
@@ -44,18 +46,21 @@ class Enterprise::Billing::CreateStripeCustomerService
price_ids.first
end
def existing_subscription?
def active_subscription
stripe_customer_id = account.custom_attributes['stripe_customer_id']
return false if stripe_customer_id.blank?
return nil if stripe_customer_id.blank?
subscriptions = Stripe::Subscription.list(
Stripe::Subscription.list(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
subscriptions.data.present?
).data.first
end
def default_plan_subscription?(subscription)
default_plan['price_ids'].include?(subscription['plan']['id'])
end
def build_custom_attributes(customer_id, subscription)
@@ -47,9 +47,8 @@ class Enterprise::Billing::HandleStripeEventService
def current_plan_credits
plan_name = account.custom_attributes['plan_name']
return { responses: 0, documents: 0 } if plan_name.blank?
get_plan_credits(plan_name)
plan_credits = get_plan_credits(plan_name) if plan_name.present?
plan_credits || { responses: 0, documents: 0 }
end
def update_account_attributes(subscription, plan)
@@ -71,19 +70,28 @@ class Enterprise::Billing::HandleStripeEventService
# skipping self hosted plan events
return if account.blank?
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
previous_monthly_credits = current_plan_credits[:responses]
return unless Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
account.with_lock do
previous_usage = { responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: previous_monthly_credits }
adjust_captain_credits(previous_usage, new_plan_credits: 0)
account.reset_response_usage
end
end
def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {}
adjust_captain_credits(previous_usage, new_plan_credits: get_plan_credits(plan['name'])[:responses])
end
def adjust_captain_credits(previous_usage, new_plan_credits:)
current_limits = account.limits || {}
current_credits = current_limits['captain_responses'].to_i
new_plan_credits = get_plan_credits(plan['name'])[:responses]
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
updated_credits = [current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits, 0].max
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
Rails.logger.info("Updating captain credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
@@ -145,10 +145,10 @@ describe Enterprise::Billing::CreateStripeCustomerService do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
end
context 'when customer has active subscriptions' do
context 'when customer has an active non-default subscription' do
before do
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
allow(subscriptions_list).to receive(:data).and_return([{ 'plan' => { 'id' => 'price_paid_plan' } }])
allow(Stripe::Subscription).to receive(:create)
end