diff --git a/Gemfile b/Gemfile
index d5e844382..faf7c2dea 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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
diff --git a/Gemfile.lock b/Gemfile.lock
index 7ce74a223..e356c13a8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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
diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js
index 3f12dc007..9e6d40a62 100644
--- a/app/javascript/dashboard/api/enterprise/account.js
+++ b/app/javascript/dashboard/api/enterprise/account.js
@@ -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();
diff --git a/app/javascript/dashboard/composables/useCaptain.js b/app/javascript/dashboard/composables/useCaptain.js
index 3f93cfc58..a9eedcc9a 100644
--- a/app/javascript/dashboard/composables/useCaptain.js
+++ b/app/javascript/dashboard/composables/useCaptain.js
@@ -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,
};
}
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index b53bfed7b..64be235a2 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -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",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
index 4ae140120..4f0981967 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
@@ -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);
@@ -178,9 +191,27 @@ onMounted(handleBillingPageLogic);
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
-
- {{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
-
+
+
+ {{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
+
+
+ {{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue
new file mode 100644
index 000000000..d557e9a71
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue
new file mode 100644
index 000000000..ebbab0aa8
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js
index 1eef59cfe..44134e19d 100644
--- a/app/javascript/dashboard/store/modules/accounts.js
+++ b/app/javascript/dashboard/store/modules/accounts.js
@@ -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 });
}
},
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 61e02ae77..bd7b3cefe 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -30,4 +30,8 @@ class AccountPolicy < ApplicationPolicy
def toggle_deletion?
@account_user.administrator?
end
+
+ def topup_checkout?
+ @account_user.administrator?
+ end
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index a624861e3..49c0f547c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index a8141531a..0ee18ef82 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
index 339fbdf3c..2c2e5fca1 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
index 5364409a1..8525aaca0 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
new file mode 100644
index 000000000..9211cd334
--- /dev/null
+++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb b/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb
new file mode 100644
index 000000000..ebdd158d8
--- /dev/null
+++ b/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb
@@ -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
diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
index 67ccd168b..db029ad0f 100644
--- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
@@ -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)