perf: update the logic to purchase credits (#12998)

## Description

- Replaces Stripe Checkout session flow with direct card charging for AI
credit top-ups
- Adds a two-step confirmation modal (select package → confirm purchase)
for better UX
- Creates Stripe invoice directly and charges the customer's default
payment method immediately

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Using the specs
- UI manual test cases

<img width="945" height="580" alt="image"
src="https://github.com/user-attachments/assets/52bdad46-cd0e-4927-b13f-54c6b6353bcc"
/>

<img width="945" height="580" alt="image"
src="https://github.com/user-attachments/assets/231bc7e9-41ac-440d-a93d-cba45a4d3e3e"
/>


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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Tanmay Deep Sharma
2025-12-08 10:52:17 +05:30
committed by GitHub
parent cc86b8c7f1
commit eb759255d8
13 changed files with 414 additions and 135 deletions

View File

@@ -245,6 +245,78 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/topup_checkout' do
let(:stripe_customer_id) { 'cus_test123' }
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test123') }
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
before do
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
end
it 'returns unauthorized for unauthenticated user' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout", as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns unauthorized for agent' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: agent.create_new_auth_token,
params: { credits: 1000 },
as: :json
expect(response).to have_http_status(:unauthorized)
end
context 'when it is an admin' do
before do
account.update!(
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 1000 }
)
allow(Stripe::Customer).to receive(:retrieve).with(stripe_customer_id).and_return(stripe_customer)
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice)
allow(Stripe::Invoice).to receive(:pay)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
it 'successfully processes topup and returns correct response' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: admin.create_new_auth_token,
params: { credits: 1000 },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['credits']).to eq(1000)
expect(json_response['amount']).to eq(20.0)
expect(json_response['limits']['captain_responses']).to eq(2000)
end
it 'returns error when credits parameter is missing' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns error for invalid credits amount' do
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
headers: admin.create_new_auth_token,
params: { credits: 999 },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do

View File

@@ -0,0 +1,60 @@
require 'rails_helper'
describe Enterprise::Billing::TopupCheckoutService do
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let(:stripe_customer_id) { 'cus_test123' }
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test') }
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
before do
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
account.update!(
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 500 }
)
allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer)
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice)
allow(Stripe::Invoice).to receive(:pay)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
describe '#create_checkout_session' do
it 'successfully processes topup and returns correct response' do
result = service.create_checkout_session(credits: 1000)
expect(result[:credits]).to eq(1000)
expect(result[:amount]).to eq(20.0)
expect(result[:currency]).to eq('usd')
end
it 'updates account limits after successful topup' do
service.create_checkout_session(credits: 1000)
expect(account.reload.limits['captain_responses']).to eq(1500)
end
it 'raises error for invalid credits' do
expect do
service.create_checkout_session(credits: 500)
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
end
it 'raises error when account is on free plan' do
account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id })
expect do
service.create_checkout_session(credits: 1000)
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
end
end
end

View File

@@ -0,0 +1,36 @@
require 'rails_helper'
describe Enterprise::Billing::TopupFulfillmentService do
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let(:stripe_customer_id) { 'cus_test123' }
before do
account.update!(
custom_attributes: { stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 1000 }
)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
describe '#fulfill' do
it 'adds credits to account limits' do
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
expect(account.reload.limits['captain_responses']).to eq(2000)
end
it 'creates a Stripe credit grant' do
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
expect(Stripe::Billing::CreditGrant).to have_received(:create).with(
hash_including(
customer: stripe_customer_id,
name: 'Topup: 1000 credits',
category: 'paid'
)
)
end
end
end