feat: Add AI credit topup flow for Stripe (#12988)

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Tanmay Deep Sharma
2025-12-03 07:23:44 +05:30
committed by GitHub
parent 1df5fd513a
commit b269cca0bf
17 changed files with 542 additions and 14 deletions

View File

@@ -162,7 +162,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe'
gem 'stripe', '~> 18.0'
## - helper gems --##
## to populate db with sample data

View File

@@ -928,7 +928,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
stripe (18.0.1)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1139,7 +1139,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe
stripe (~> 18.0)
telephone_number
test-prof
tidewave

View File

@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
}
export default new EnterpriseAccountAPI();

View File

@@ -1,5 +1,5 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -9,6 +9,7 @@ export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -34,6 +35,8 @@ export function useCaptain() {
return null;
});
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
@@ -46,5 +49,6 @@ export function useCaptain() {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
};
}

View File

@@ -399,15 +399,31 @@
"DESCRIPTION": "Manage usage and credits for Captain AI.",
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
"RESPONSES": "Credits",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
"REFRESH_CREDITS": "Refresh"
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
"TOPUP": {
"BUY_CREDITS": "Buy more credits",
"MODAL_TITLE": "Buy AI Credits",
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
"CREDITS": "CREDITS",
"ONE_TIME": "one-time",
"POPULAR": "Most Popular",
"NOTE_TITLE": "Note:",
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
"CANCEL": "Cancel",
"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."
}
},
"SECURITY_SETTINGS": {
"TITLE": "Security",

View File

@@ -11,6 +11,7 @@ import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
@@ -23,6 +24,7 @@ const {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
@@ -32,6 +34,7 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
@@ -45,6 +48,11 @@ const planName = computed(() => {
return customAttributes.value.plan_name;
});
const canPurchaseCredits = computed(() => {
const plan = planName.value?.toLowerCase();
return plan && plan !== 'hacker';
});
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
@@ -71,8 +79,9 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
fetchLimits();
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
};
const handleBillingPageLogic = async () => {
@@ -119,6 +128,10 @@ const onToggleChatWindow = () => {
}
};
const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
onMounted(handleBillingPageLogic);
</script>
@@ -178,9 +191,27 @@ onMounted(handleBillingPageLogic);
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
<template #action>
<ButtonV4 sm faded slate disabled>
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
</ButtonV4>
<div class="flex gap-2">
<ButtonV4
sm
flushed
slate
icon="i-lucide-refresh-cw"
:is-loading="isFetchingLimits"
@click="fetchLimits"
>
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
</ButtonV4>
<ButtonV4
v-if="canPurchaseCredits"
sm
solid
blue
@click="openPurchaseCreditsModal"
>
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
</ButtonV4>
</div>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<BillingMeter
@@ -223,6 +254,7 @@ onMounted(handleBillingPageLogic);
</ButtonV4>
</BillingHeader>
</section>
<PurchaseCreditsModal ref="purchaseCreditsModalRef" />
</template>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
defineProps({
credits: {
type: Number,
required: true,
},
amount: {
type: Number,
required: true,
},
currency: {
type: String,
default: 'usd',
},
isSelected: {
type: Boolean,
default: false,
},
isPopular: {
type: Boolean,
default: false,
},
name: {
type: String,
required: true,
},
});
const emit = defineEmits(['select']);
const formatCredits = credits => {
return credits.toLocaleString();
};
const formatAmount = (amount, currency) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
};
</script>
<template>
<label
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
:class="[
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
]"
>
<input
type="radio"
:name="name"
:value="credits"
:checked="isSelected"
class="sr-only"
@change="emit('select')"
/>
<span
v-if="isPopular"
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
:class="
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
"
>
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
</span>
<div
v-if="isSelected"
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
{{ formatCredits(credits) }}
</span>
<span
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
>
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
</span>
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
{{ formatAmount(amount, currency) }}
<span class="text-sm text-n-slate-11 ml-0.5">{{
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
}}</span>
</span>
</label>
</template>

View File

@@ -0,0 +1,128 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
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 { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const dialogRef = ref(null);
const selectedCredits = ref(null);
const isLoading = ref(false);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const handlePurchase = async () => {
if (!selectedOption.value) return;
isLoading.value = true;
try {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url;
}
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
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"
: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>
<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 #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
class="w-full"
@click="close"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
:is-loading="isLoading"
@click="handlePurchase"
/>
</div>
</template>
</Dialog>
</template>

View File

@@ -18,6 +18,7 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingLimits: false,
},
};
@@ -141,11 +142,14 @@ export const actions = {
},
limits: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},

View File

@@ -30,4 +30,8 @@ class AccountPolicy < ApplicationPolicy
def toggle_deletion?
@account_user.administrator?
end
def topup_checkout?
@account_user.administrator?
end
end

View File

@@ -122,6 +122,11 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
topup:
credits_required: Credits amount is required
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.
profile:
mfa:
enabled: MFA enabled successfully

View File

@@ -24,6 +24,7 @@ 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
@@ -438,6 +439,7 @@ Rails.application.routes.draw do
post :subscription
get :limits
post :toggle_deletion
post :topup_checkout
end
end
end

View File

@@ -55,6 +55,17 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
end
end
def topup_checkout
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}")
render_could_not_create_error(e.message)
end
private
def check_cloud_env

View File

@@ -1,5 +1,6 @@
class Enterprise::Billing::HandleStripeEventService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
@@ -33,6 +34,8 @@ 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
@@ -46,9 +49,25 @@ class Enterprise::Billing::HandleStripeEventService
# skipping self hosted plan events
return if plan.blank? || account.blank?
previous_usage = capture_previous_usage
update_account_attributes(subscription, plan)
update_plan_features
reset_captain_usage
handle_subscription_credits(plan, previous_usage)
account.reset_response_usage
end
def capture_previous_usage
{
responses: account.custom_attributes['captain_responses_usage'].to_i,
monthly: current_plan_credits[:responses]
}
end
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)
end
def update_account_attributes(subscription, plan)
@@ -73,6 +92,31 @@ 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
@@ -101,8 +145,23 @@ class Enterprise::Billing::HandleStripeEventService
enable_plan_specific_features
end
def reset_captain_usage
account.reset_response_usage
def handle_subscription_credits(plan, previous_usage)
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
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
def get_plan_credits(plan_name)
config = InstallationConfig.find_by(name: CAPTAIN_CLOUD_PLAN_LIMITS).value
config = JSON.parse(config) if config.is_a?(String)
config[plan_name.downcase]&.symbolize_keys
end
def enable_plan_specific_features

View File

@@ -0,0 +1,99 @@
class Enterprise::Billing::TopupCheckoutService
include BillingHelper
include Rails.application.routes.url_helpers
class Error < StandardError; end
TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12_000, amount: 200.0, currency: 'usd' }
].freeze
pattr_initialize [:account!]
def create_checkout_session(credits:)
topup_option = validate_and_find_topup_option(credits)
session = create_stripe_session(topup_option, credits)
session.url
end
private
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)
topup_option = find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
topup_option
end
def create_stripe_session(topup_option, credits)
Stripe::Checkout::Session.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)
)
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)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
def find_topup_option(credits)
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
end
end

View File

@@ -0,0 +1,49 @@
class Enterprise::Billing::TopupFulfillmentService
pattr_initialize [:account!]
def fulfill(credits:, amount_cents:, currency:)
account.with_lock do
create_stripe_credit_grant(credits, amount_cents, currency)
update_account_credits(credits)
end
end
private
def create_stripe_credit_grant(credits, amount_cents, currency)
Stripe::Billing::CreditGrant.create(
customer: stripe_customer_id,
name: "Topup: #{credits} credits",
amount: {
type: 'monetary',
monetary: { currency: currency, value: amount_cents }
},
applicability_config: {
scope: { price_type: 'metered' }
},
category: 'paid',
expires_at: 6.months.from_now.to_i,
metadata: {
account_id: account.id.to_s,
source: 'topup',
credits: credits.to_s
}
)
end
def update_account_credits(credits)
current_limits = account.limits || {}
current_total = current_limits['captain_responses'].to_i
new_total = current_total + credits
account.update!(
limits: current_limits.merge(
'captain_responses' => new_total
)
)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
end

View File

@@ -19,6 +19,16 @@ describe Enterprise::Billing::HandleStripeEventService do
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
]
})
create(:installation_config, {
name: 'CAPTAIN_CLOUD_PLAN_LIMITS',
value: {
'hacker' => { 'responses' => 0 },
'startups' => { 'responses' => 300 },
'business' => { 'responses' => 500 },
'enterprise' => { 'responses' => 800 }
}
})
# Setup common subscription mocks
allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription)