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:
committed by
GitHub
parent
1df5fd513a
commit
b269cca0bf
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user