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

@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
expect(accountAPI).toHaveProperty('createTopupCheckout');
expect(accountAPI).toHaveProperty('getLimits');
});
describe('API calls', () => {
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
{ action_type: 'undelete' }
);
});
it('#createTopupCheckout with credits', () => {
accountAPI.createTopupCheckout(1000);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits: 1000 }
);
});
it('#createTopupCheckout with different credit amounts', () => {
const creditAmounts = [1000, 2500, 6000, 12000];
creditAmounts.forEach(credits => {
accountAPI.createTopupCheckout(credits);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits }
);
});
});
it('#getLimits', () => {
accountAPI.getLimits();
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
});
});
});

View File

@@ -422,7 +422,15 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"PURCHASE_ERROR": "Failed to process purchase. Please try again."
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
"TITLE": "Confirm Purchase",
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
"GO_BACK": "Go Back",
"CONFIRM_PURCHASE": "Confirm Purchase"
}
}
},
"SECURITY_SETTINGS": {

View File

@@ -132,6 +132,11 @@ const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
const handleTopupSuccess = () => {
// Refresh limits to show updated credit balance
fetchLimits();
};
onMounted(handleBillingPageLogic);
</script>
@@ -254,7 +259,10 @@ onMounted(handleBillingPageLogic);
</ButtonV4>
</BillingHeader>
</section>
<PurchaseCreditsModal ref="purchaseCreditsModalRef" />
<PurchaseCreditsModal
ref="purchaseCreditsModalRef"
@success="handleTopupSuccess"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
const emit = defineEmits(['close']);
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
@@ -19,19 +19,78 @@ const TOPUP_OPTIONS = [
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
const dialogRef = ref(null);
const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
if (!selectedOption.value) return '';
return selectedOption.value.credits.toLocaleString();
});
const dialogTitle = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
});
const dialogDescription = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
: '';
});
const dialogWidth = computed(() => {
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
};
const goBackToSelectStep = () => {
currentStep.value = STEP_SELECT;
};
const handlePurchase = async () => {
if (!selectedOption.value) return;
@@ -40,9 +99,14 @@ const handlePurchase = async () => {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url;
}
close();
emit('success', response.data);
useAlert(
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
credits: response.data.credits,
})
);
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
@@ -52,61 +116,71 @@ const handlePurchase = async () => {
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
// Pre-select the most popular option
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')"
:description="$t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')"
width="xl"
:title="dialogTitle"
:description="dialogDescription"
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
<template v-else>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
credits: formattedCredits,
amount: formattedAmount,
})
}}
</p>
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
<p class="text-sm text-n-amber-11">
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
</p>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<!-- Step 1 Footer -->
<div
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="faded"
color="slate"
@@ -119,6 +193,24 @@ defineExpose({ open, close });
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
<!-- Step 2 Footer -->
<div v-else class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
class="w-full"
:disabled="isLoading"
@click="goBackToSelectStep"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
class="w-full"
:is-loading="isLoading"
@click="handlePurchase"
/>

View File

@@ -127,6 +127,8 @@ en:
invalid_credits: Invalid credits amount
invalid_option: Invalid topup option
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
stripe_customer_not_configured: Stripe customer not configured
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
profile:
mfa:
enabled: MFA enabled successfully

View File

@@ -24,7 +24,6 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
get '/app/accounts/:account_id/settings/billing', to: 'dashboard#index', as: 'app_account_billing_settings'
resource :widget, only: [:show]
namespace :survey do

View File

@@ -59,10 +59,15 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
redirect_url = service.create_checkout_session(credits: params[:credits].to_i)
render json: { redirect_url: redirect_url }
rescue Enterprise::Billing::TopupCheckoutService::Error => e
Rails.logger.error("Topup checkout failed for account #{@account.id}: #{e.message}")
result = service.create_checkout_session(credits: params[:credits].to_i)
@account.reload
render json: result.merge(
id: @account.id,
limits: @account.limits,
custom_attributes: @account.custom_attributes
)
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
render_could_not_create_error(e.message)
end

View File

@@ -34,8 +34,6 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
when 'checkout.session.completed'
process_checkout_session_completed
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
@@ -92,31 +90,6 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def process_checkout_session_completed
session = @event.data.object
metadata = session.metadata
# Only process topup checkout sessions
return unless metadata.present? && metadata['topup'] == 'true'
topup_account = Account.find_by(id: metadata['account_id'])
return if topup_account.blank?
credits = metadata['credits'].to_i
amount_cents = metadata['amount_cents'].to_i
currency = metadata['currency'] || 'usd'
Rails.logger.info("Processing topup for account #{topup_account.id}: #{credits} credits, #{amount_cents} cents")
Enterprise::Billing::TopupFulfillmentService.new(account: topup_account).fulfill(
credits: credits,
amount_cents: amount_cents,
currency: currency
)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: topup_account).capture_exception
raise
end
def update_plan_features
if default_plan?
disable_all_premium_features

View File

@@ -1,6 +1,5 @@
class Enterprise::Billing::TopupCheckoutService
include BillingHelper
include Rails.application.routes.url_helpers
class Error < StandardError; end
@@ -15,8 +14,14 @@ class Enterprise::Billing::TopupCheckoutService
def create_checkout_session(credits:)
topup_option = validate_and_find_topup_option(credits)
session = create_stripe_session(topup_option, credits)
session.url
charge_customer(topup_option, credits)
fulfill_credits(credits, topup_option)
{
credits: credits,
amount: topup_option[:amount],
currency: topup_option[:currency]
}
end
private
@@ -24,69 +29,60 @@ class Enterprise::Billing::TopupCheckoutService
def validate_and_find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
raise Error, I18n.t('errors.topup.stripe_customer_not_configured') if stripe_customer_id.blank?
topup_option = find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
# Validate payment method exists
validate_payment_method!
topup_option
end
def create_stripe_session(topup_option, credits)
Stripe::Checkout::Session.create(
def validate_payment_method!
customer = Stripe::Customer.retrieve(stripe_customer_id)
return if customer.invoice_settings.default_payment_method.present? || customer.default_source.present?
# Auto-set first payment method as default if available
payment_methods = Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 1)
raise Error, I18n.t('errors.topup.no_payment_method') if payment_methods.data.empty?
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_methods.data.first.id })
end
def charge_customer(topup_option, credits)
amount_cents = (topup_option[:amount] * 100).to_i
currency = topup_option[:currency]
description = "AI Credits Topup: #{credits} credits"
invoice = Stripe::Invoice.create(
customer: stripe_customer_id,
mode: 'payment',
line_items: [build_line_item(topup_option, credits)],
success_url: success_url,
cancel_url: cancel_url,
metadata: session_metadata(credits, topup_option),
payment_method_types: ['card'],
# Show saved payment methods and allow saving new ones
saved_payment_method_options: {
payment_method_save: 'enabled',
allow_redisplay_filters: %w[always limited]
},
# Create invoice for this payment so it appears in customer portal
invoice_creation: build_invoice_creation_data(credits, topup_option)
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false,
description: description
)
Stripe::InvoiceItem.create(
customer: stripe_customer_id,
amount: amount_cents,
currency: currency,
invoice: invoice.id,
description: description
)
Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false })
Stripe::Invoice.pay(invoice.id)
end
def build_invoice_creation_data(credits, topup_option)
{
enabled: true,
invoice_data: {
description: "AI Credits Topup: #{credits} credits",
metadata: session_metadata(credits, topup_option)
}
}
end
def build_line_item(topup_option, credits)
{
price_data: {
currency: topup_option[:currency],
unit_amount: (topup_option[:amount] * 100).to_i,
product_data: { name: "AI Credits Topup: #{credits} credits" }
},
quantity: 1
}
end
def session_metadata(credits, topup_option)
{
account_id: account.id.to_s,
credits: credits.to_s,
amount_cents: (topup_option[:amount] * 100).to_s,
currency: topup_option[:currency],
topup: 'true'
}
end
def success_url
app_account_billing_settings_url(account_id: account.id, topup: 'success')
end
def cancel_url
app_account_billing_settings_url(account_id: account.id)
def fulfill_credits(credits, topup_option)
Enterprise::Billing::TopupFulfillmentService.new(account: account).fulfill(
credits: credits,
amount_cents: (topup_option[:amount] * 100).to_i,
currency: topup_option[:currency]
)
end
def stripe_customer_id

View File

@@ -6,6 +6,8 @@ class Enterprise::Billing::TopupFulfillmentService
create_stripe_credit_grant(credits, amount_cents, currency)
update_account_credits(credits)
end
Rails.logger.info("Topup fulfilled for account #{account.id}: #{credits} credits, #{amount_cents} cents")
end
private

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