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

@@ -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