+
+
+
+
+
+
+
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6ddc9adde..3cc8eb9cf 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 0ee18ef82..9b22cb333 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
index 2c2e5fca1..0f829b973 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb
@@ -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
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 8525aaca0..ecbc481a4 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
index 9211cd334..e11269110 100644
--- a/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
+++ b/enterprise/app/services/enterprise/billing/topup_checkout_service.rb
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb b/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb
index ebdd158d8..9e70918b8 100644
--- a/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb
+++ b/enterprise/app/services/enterprise/billing/topup_fulfillment_service.rb
@@ -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
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
index 33941cee8..e79cbd237 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb
@@ -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
diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
new file mode 100644
index 000000000..8a64e6fd2
--- /dev/null
+++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb
@@ -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
diff --git a/spec/enterprise/services/enterprise/billing/topup_fulfillment_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_fulfillment_service_spec.rb
new file mode 100644
index 000000000..2dda12f35
--- /dev/null
+++ b/spec/enterprise/services/enterprise/billing/topup_fulfillment_service_spec.rb
@@ -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