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')" >
+ 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)