use env variable for stripe v2

This commit is contained in:
Tanmay Sharma
2025-10-16 16:37:52 +05:30
parent 7c3b07c507
commit ac1743514b
13 changed files with 311 additions and 41 deletions
+1 -1
View File
@@ -35,7 +35,6 @@ class DashboardController < ActionController::Base
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN'
).merge(app_config)
end
@@ -71,6 +70,7 @@ class DashboardController < ActionController::Base
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
DEPLOYMENT_ENV: GlobalConfigService.load('DEPLOYMENT_ENV', 'self-hosted'),
GIT_SHA: GIT_HASH
}
end
+1 -1
View File
@@ -6,7 +6,7 @@
# amount :integer not null
# credit_type :string not null
# description :string
# metadata :jsonb
# metadata :json
# transaction_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -57,7 +57,7 @@ class Enterprise::Webhooks::StripeController < ActionController::API
end
end
def account_v2_enabled?(account)
account.custom_attributes&.[]('stripe_billing_version').to_i == 2
def account_v2_enabled?(_account)
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
end
end
@@ -2,6 +2,19 @@ class Enterprise::CreateStripeCustomerJob < ApplicationJob
queue_as :default
def perform(account)
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
# Check if this is a V2 billing account
if v2_billing_enabled?
# Use V2 service - creates customer and subscribes to free Hacker plan
Enterprise::Billing::V2::CustomerCreationService.new(account: account).perform
else
# Use V1 service - legacy billing
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
end
private
def v2_billing_enabled?
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
end
end
@@ -18,6 +18,6 @@ class Enterprise::Ai::CaptainCreditService
private
def v2_enabled?
account.custom_attributes&.[]('stripe_billing_version').to_i == 2
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
end
end
@@ -1,9 +1,12 @@
class Enterprise::Billing::CreateSessionService
PORTAL_CONFIGURATION_ID = 'bpc_1SI88PF3O6TPVU2azyI0ek2W'.freeze
def create_session(customer_id, return_url = ENV.fetch('FRONTEND_URL'))
Stripe::BillingPortal::Session.create(
{
customer: customer_id,
return_url: return_url
return_url: return_url,
configuration: PORTAL_CONFIGURATION_ID
}
)
end
@@ -14,7 +14,7 @@ class Enterprise::Billing::V2::BaseService
end
def v2_enabled?
custom_attribute('stripe_billing_version').to_i == 2
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
end
def monthly_credits
@@ -0,0 +1,68 @@
# Service to create Stripe customer for V2 billing with default Hacker pricing plan
class Enterprise::Billing::V2::CustomerCreationService < Enterprise::Billing::V2::BaseService
def perform
return { success: false, message: 'Customer already exists' } if customer_exists?
return { success: false, message: 'Not a V2 billing account' } unless v2_enabled?
return { success: false, message: 'Hacker pricing plan not configured' } unless hacker_plan_id
with_locked_account do
customer = create_stripe_customer
return { success: false, message: 'Failed to create customer' } unless customer
save_customer_id(customer.id)
subscribe_to_hacker_pricing_plan(customer.id)
end
rescue Stripe::StripeError => e
{ success: false, message: e.message }
end
private
def customer_exists?
custom_attribute('stripe_customer_id').present?
end
def hacker_plan_id
# Get Hacker plan pricing_plan_id from environment or installation config
# Set via: ENV['STRIPE_HACKER_PRICING_PLAN_ID'] or InstallationConfig
config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PRICING_PLAN_ID')
config&.value || ENV.fetch('STRIPE_HACKER_PRICING_PLAN_ID', nil)
end
def create_stripe_customer
Stripe::Customer.create(
{
email: billing_email,
name: account.name,
description: "Chatwoot Account ##{account.id}",
metadata: {
account_id: account.id.to_s,
account_name: account.name,
billing_version: '2',
plan_type: 'hacker'
}
},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
end
def save_customer_id(customer_id)
update_custom_attributes(
'stripe_customer_id' => customer_id,
'stripe_billing_version' => 2
)
end
def subscribe_to_hacker_pricing_plan(customer_id)
# Shared meter and event name are automatically loaded from ENV in SubscribeCustomerService
Enterprise::Billing::V2::SubscribeCustomerService.new(account: account)
.subscribe_to_pricing_plan(
pricing_plan_id: hacker_plan_id,
customer_id: customer_id
)
end
def billing_email
account.administrators.first&.email || "account_#{account.id}@chatwoot.com"
end
end
@@ -35,15 +35,9 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
end
def create_complete_pricing_plan(config)
cpu = create_custom_pricing_unit(
display_name: config[:cpu_display_name],
lookup_key: config[:cpu_lookup_key]
)
meter = create_meter(
display_name: config[:meter_display_name],
event_name: config[:meter_event_name]
)
# Use shared CPU and meter if available, otherwise create new ones
cpu = get_or_create_cpu(config)
meter = get_or_create_meter(config)
plan = create_pricing_plan(display_name: config[:plan_display_name])
@@ -53,11 +47,47 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
service_action = builder.add_service_action_component(plan, config, cpu)
rate_card = builder.add_rate_card_component(plan, config, meter, cpu)
build_plan_result(plan, cpu, meter, rate_card, service_action)
result = build_plan_result(plan, cpu, meter, rate_card, service_action)
Enterprise::Billing::V2::PricingPlanCache.invalidate
result
rescue StandardError => e
{ success: false, message: e.message }
end
def get_or_create_cpu(config)
# Check for shared CPU first
shared_cpu_id = InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value ||
ENV.fetch('STRIPE_CUSTOM_PRICING_UNIT_ID', nil)
if shared_cpu_id
# Return existing CPU as OpenStruct to match create response
OpenStruct.new(id: shared_cpu_id)
else
# Create new CPU if not using shared
create_custom_pricing_unit(
display_name: config[:cpu_display_name],
lookup_key: config[:cpu_lookup_key]
)
end
end
def get_or_create_meter(config)
# Check for shared meter first
shared_meter_id = InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value ||
ENV.fetch('STRIPE_METER_ID', nil)
if shared_meter_id
# Return existing meter as OpenStruct to match create response
OpenStruct.new(id: shared_meter_id)
else
# Create new meter if not using shared
create_meter(
display_name: config[:meter_display_name],
event_name: config[:meter_event_name]
)
end
end
private
def component_builder
@@ -1,11 +1,15 @@
# rubocop:disable Metrics/ClassLength
class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::V2::Concerns::PaymentIntentHandler
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::PlanFeatureManager
def subscribe_to_pricing_plan(pricing_plan_id:, customer_id: nil, meter_id: nil, meter_event_name: nil)
@pricing_plan_id = pricing_plan_id
@customer_id = customer_id || stripe_customer_id
@meter_id = meter_id
@meter_event_name = meter_event_name
# Use shared meter from ENV if not explicitly provided
@meter_id = meter_id || shared_meter_id
@meter_event_name = meter_event_name || shared_meter_event_name
validate_subscription_params
execute_subscription_flow
@@ -36,7 +40,9 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V
return { success: false, message: 'Failed to create billing intent' } unless intent
reserve_and_commit_intent(intent.id)
update_account_subscription_info(pricing_plan)
update_account_subscription_info(pricing_plan, cadence.id)
refresh_monthly_credits(pricing_plan)
enable_plan_features(pricing_plan)
build_subscription_result(cadence.id, intent.id)
end
@@ -147,15 +153,21 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V
end
def commit_intent(intent_id)
ensure_payment_method
intent = fetch_billing_intent(intent_id)
payment_intent_id = create_payment_if_needed(intent, intent_id)
# Only require payment method if there's an amount due
ensure_payment_method(intent) if requires_payment?(intent)
payment_intent_id = create_payment_if_needed(intent, intent_id)
commit_billing_intent(intent_id, payment_intent_id)
end
def ensure_payment_method
def requires_payment?(intent)
amount_due = intent.amount_details&.total || intent.amount_details.total
amount_due&.to_i&.positive?
end
def ensure_payment_method(_intent)
# Check if customer already has a payment method
customer = Stripe::Customer.retrieve(
@customer_id,
@@ -170,11 +182,12 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V
'Payment method required. Customer must add payment method via Stripe Checkout or SetupIntent before subscribing.'
end
def update_account_subscription_info(pricing_plan)
def update_account_subscription_info(pricing_plan, cadence_id)
attributes = {
'stripe_billing_version' => 2,
'stripe_customer_id' => @customer_id,
'stripe_pricing_plan_id' => @pricing_plan_id,
'stripe_cadence_id' => cadence_id,
'plan_name' => extract_plan_name(pricing_plan),
'subscription_status' => 'active'
}
@@ -189,11 +202,121 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V
display_name = pricing_plan['display_name'] || pricing_plan[:display_name]
return 'Business' unless display_name
# Extract plan name from display name like "Chatwoot Business - 2000 Credits"
display_name.split('-').first.strip.split.last || 'Business'
# Extract plan name from display name
# Supports formats:
# - "Chatwoot Business - 2000 Credits" → Business
# - "Chatwoot Startup Plan" → Startup
name_part = display_name.split('-').first.strip
# Try to find known tier names in the string
%w[Enterprise Business Startup Hacker].each do |tier|
return tier if name_part.include?(tier)
end
# Fallback: try to extract last word before "Plan" or just last word
name_part.gsub(/\s+Plan$/, '').strip.split.last || 'Business'
end
def refresh_monthly_credits(pricing_plan)
credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(@pricing_plan_id)
credits ||= extract_credits_from_pricing_plan(pricing_plan)
return unless credits
Enterprise::Billing::V2::CreditManagementService
.new(account: account)
.sync_monthly_credits(credits.to_i)
end
def extract_credits_from_pricing_plan(pricing_plan)
components = get_plan_components(pricing_plan)
return unless components&.any?
find_service_action_credits(components)
end
def get_plan_components(pricing_plan)
components = pricing_plan['components']
components&.any? ? components : fetch_plan_components
end
def find_service_action_credits(components)
components.each do |component|
next unless safe_fetch(component, :type) == 'service_action'
amount = service_action_credit_amount(component)
return amount if amount.present?
end
nil
end
def fetch_plan_components
response = StripeV2Client.request(
:get,
"/v2/billing/pricing_plans/#{@pricing_plan_id}/components",
{},
stripe_api_options
)
response.data
rescue StandardError => e
Rails.logger.error("Failed to fetch plan components for #{@pricing_plan_id}: #{e.message}")
[]
end
def service_action_credit_amount(component)
action_id = safe_fetch(component, :service_action, :id)
return unless action_id
action_response = StripeV2Client.request(
:get,
"/v2/billing/service_actions/#{action_id}",
{},
stripe_api_options
)
credit = action_response.credit_grant&.amount&.custom_pricing_unit&.value
credit&.to_i
rescue StandardError => e
Rails.logger.error("Failed to fetch service action #{action_id}: #{e.message}")
nil
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def shared_meter_id
InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value ||
ENV.fetch('STRIPE_METER_ID', nil)
end
def shared_meter_event_name
InstallationConfig.find_by(name: 'STRIPE_METER_EVENT_NAME')&.value ||
ENV.fetch('STRIPE_METER_EVENT_NAME', nil)
end
def safe_fetch(object, *keys)
keys.reduce(object) do |memo, key|
break nil if memo.nil?
if memo.respond_to?(key)
memo.public_send(key)
elsif memo.respond_to?(:[])
memo[key.to_s] || memo[key]
end
end
end
def enable_plan_features(pricing_plan)
plan_name = extract_plan_name(pricing_plan)
Rails.logger.info "Enabling features for V2 plan: #{plan_name} (account #{account.id})"
# Use shared feature management logic
update_plan_features(plan_name)
# Reset AI usage counters
reset_captain_usage
Rails.logger.info "Features enabled for account #{account.id}: #{account.enabled_features.keys.join(', ')}"
end
end
# rubocop:enable Metrics/ClassLength
@@ -16,13 +16,12 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
private
def valid_configuration?
custom_attribute('stripe_customer_id').present? &&
custom_attribute('stripe_meter_event_name').present?
custom_attribute('stripe_customer_id').present? && meter_event_name.present?
end
def meter_event_params(credits_used)
{
event_name: custom_attribute('stripe_meter_event_name'),
event_name: meter_event_name,
payload: {
value: credits_used.to_s,
stripe_customer_id: custom_attribute('stripe_customer_id')
@@ -31,6 +30,15 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
}
end
def meter_event_name
# Use shared meter event name from ENV/InstallationConfig (preferred)
# Falls back to account-specific event name for backward compatibility
shared_event_name = InstallationConfig.find_by(name: 'STRIPE_METER_EVENT_NAME')&.value ||
ENV.fetch('STRIPE_METER_EVENT_NAME', nil)
shared_event_name || custom_attribute('stripe_meter_event_name')
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
@@ -105,6 +105,6 @@ module Enterprise::Integrations::OpenaiProcessorService
end
def v2_enabled?
hook.account.custom_attributes&.[]('stripe_billing_version').to_i == 2
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
end
end
@@ -12,16 +12,41 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do
.on_queue('default')
end
it 'executes perform' do
create_stripe_customer_service = double
allow(Enterprise::Billing::CreateStripeCustomerService)
.to receive(:new)
.with(account: account)
.and_return(create_stripe_customer_service)
allow(create_stripe_customer_service).to receive(:perform)
context 'when V1 billing' do
before do
allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('false')
end
perform_enqueued_jobs { job }
it 'uses V1 customer creation service' do
create_stripe_customer_service = double
allow(Enterprise::Billing::CreateStripeCustomerService)
.to receive(:new)
.with(account: account)
.and_return(create_stripe_customer_service)
allow(create_stripe_customer_service).to receive(:perform)
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account)
perform_enqueued_jobs { job }
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account)
end
end
context 'when V2 billing' do
before do
allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('true')
end
it 'uses V2 customer creation service' do
v2_service = double
allow(Enterprise::Billing::V2::CustomerCreationService)
.to receive(:new)
.with(account: account)
.and_return(v2_service)
allow(v2_service).to receive(:perform)
perform_enqueued_jobs { job }
expect(Enterprise::Billing::V2::CustomerCreationService).to have_received(:new).with(account: account)
end
end
end