use env variable for stripe v2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user