fix: prevent lost custom_attributes updates from concurrent jsonb writes (#14040)
## Linear ticket https://linear.app/chatwoot/issue/CW-6834/billing-upgrade-didnt-work ## Description A `customer.subscription.updated` Stripe webhook for account 76162 returned 200 OK but did not persist the new `subscribed_quantity`. Root cause: a race condition between the webhook handler and `increment_response_usage` (Captain usage counter), both doing read-modify-write on the `custom_attributes` JSONB column. The webhook wrote `quantity: 6`, then a concurrent `save` from `increment_response_usage` overwrote the entire hash with stale data — restoring `quantity: 5`. Fix: use atomic `jsonb_set` so usage counter updates only touch the single key they care about, instead of rewriting the whole `custom_attributes` hash. `increment_custom_attribute` also performs the increment in SQL, making concurrent increments correct as well. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - New regression spec in `handle_stripe_event_service_spec.rb` that simulates concurrent webhook + `increment_response_usage` and asserts both `subscribed_quantity` and `captain_responses_usage` survive - Existing account, billing, captain, and topup specs all pass locally ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] 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:
committed by
GitHub
parent
45b6ea6b3f
commit
0592cccca9
@@ -83,6 +83,37 @@ describe Enterprise::Billing::HandleStripeEventService do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'subscription quantity update' do
|
||||
before do
|
||||
allow(subscription).to receive(:[]).with('plan')
|
||||
.and_return({ 'id' => 'price_startups', 'product' => 'plan_id_startups', 'name' => 'Startups' })
|
||||
end
|
||||
|
||||
it 'updates subscribed_quantity' do
|
||||
allow(subscription).to receive(:[]).with('quantity').and_return(6)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(account.reload.custom_attributes['subscribed_quantity']).to eq(6)
|
||||
end
|
||||
|
||||
it 'persists quantity even when increment_response_usage runs concurrently' do
|
||||
allow(subscription).to receive(:[]).with('quantity').and_return(6)
|
||||
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100))
|
||||
|
||||
# Simulate: webhook updates quantity, then a concurrent increment_response_usage writes usage
|
||||
stripe_event_service.new.perform(event: event)
|
||||
account.reload
|
||||
|
||||
# Simulate concurrent increment_response_usage (atomic jsonb_set, not full hash overwrite)
|
||||
account.increment_response_usage
|
||||
|
||||
# Quantity must survive the concurrent usage update
|
||||
expect(account.reload.custom_attributes['subscribed_quantity']).to eq(6)
|
||||
expect(account.reload.custom_attributes['captain_responses_usage']).to eq(101)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'subscription deletion handling' do
|
||||
it 'calls CreateStripeCustomerService on subscription deletion' do
|
||||
allow(event).to receive(:type).and_return('customer.subscription.deleted')
|
||||
|
||||
Reference in New Issue
Block a user