implement checkout endpoint for subscription in a better way

This commit is contained in:
Tanmay Sharma
2025-10-27 00:28:52 +05:30
parent ac1743514b
commit 39af360a18
35 changed files with 2130 additions and 850 deletions
+5
View File
@@ -99,3 +99,8 @@ CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.pnpm-store/
# External documentation
\[External\]*
+11
View File
@@ -108,6 +108,7 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_create :initialize_v2_billing
after_destroy :remove_account_sequences
def agents
@@ -165,6 +166,16 @@ class Account < ApplicationRecord
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
end
def initialize_v2_billing
# Initialize V2 billing for Chatwoot Cloud accounts
return unless ChatwootApp.chatwoot_cloud?
# Set stripe_billing_version to 2 for all new accounts
# rubocop:disable Rails/SkipsModelValidations
update_columns(custom_attributes: (custom_attributes || {}).merge('stripe_billing_version' => 2))
# rubocop:enable Rails/SkipsModelValidations
end
trigger.after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
end
+29 -1
View File
@@ -32,6 +32,34 @@ class AccountPolicy < ApplicationPolicy
end
def credits_balance?
@account_user.administrator? || @account_user.agent?
@account_user.administrator?
end
def v2_pricing_plans?
@account_user.administrator?
end
def v2_topup_options?
@account_user.administrator?
end
def v2_topup?
@account_user.administrator?
end
def v2_buy?
@account_user.administrator?
end
def v2_subscribe?
@account_user.administrator?
end
def cancel_subscription?
@account_user.administrator?
end
def update_subscription?
@account_user.administrator?
end
end
@@ -15,6 +15,11 @@ if resource.custom_attributes.present?
if resource.custom_attributes['marked_for_deletion_reason'].present?
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
end
# V2 Billing attributes
json.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present?
json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present?
json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present?
json.stripe_cadence_id resource.custom_attributes['stripe_cadence_id'] if resource.custom_attributes['stripe_cadence_id'].present?
end
end
json.domain @account.domain
+6
View File
@@ -433,6 +433,12 @@ Rails.application.routes.draw do
post :toggle_deletion
# V2 Billing endpoints
get :credits_balance
get :v2_pricing_plans
get :v2_topup_options
post :v2_topup
post :v2_subscribe
post :cancel_subscription
post :update_subscription
end
end
end
+142
View File
@@ -0,0 +1,142 @@
# Validate environment before starting
if ENV['STRIPE_SECRET_KEY'].blank?
puts '❌ ERROR: STRIPE_SECRET_KEY environment variable is not set'
puts 'Please set it with: export STRIPE_SECRET_KEY=sk_test_...'
exit 1
end
account = Account.first
if account.nil?
puts '❌ ERROR: No accounts found in database'
puts 'Please create an account first'
exit 1
end
puts "Using account: #{account.name} (ID: #{account.id})"
puts "Stripe API: #{ENV.fetch('STRIPE_SECRET_KEY', nil)[0..10]}..."
puts ''
configs = [
{
cpu_display_name: 'Credits',
cpu_lookup_key: 'cpu_credits',
meter_display_name: 'Cw meter',
meter_event_name: 'chatwoot.usage',
plan_display_name: 'Chatwoot Hacker',
plan_lookup_key: 'chatwoot_hacker_v2',
lookup_key: 'chatwoot_hacker_license_fee_v2',
service_action_lookup_key: 'chatwoot_hacker_plan_v2_credits',
metered_item_lookup_key: 'chatwoot_hacker_plan_v2_usage',
licensed_item_display_name: 'Seat',
licensed_item_unit_label: 'per agent',
license_fee_display_name: 'Fee',
license_fee_amount: '0',
monthly_credit_amount: 0,
rate_card_display_name: 'Rates',
metered_item_display_name: 'Prompt',
rate_value: 1,
config_key: 'STRIPE_HACKER_PLAN_ID'
},
{
cpu_display_name: 'Credits',
cpu_lookup_key: 'cpu_credits',
meter_display_name: 'Cw meter',
meter_event_name: 'chatwoot.usage',
plan_display_name: 'Chatwoot Startup',
plan_lookup_key: 'chatwoot_startup_v2',
lookup_key: 'chatwoot_startup_license_fee_v2',
service_action_lookup_key: 'chatwoot_startup_plan_v2_credits',
metered_item_lookup_key: 'chatwoot_startup_plan_v2_usage',
licensed_item_display_name: 'Seat',
licensed_item_unit_label: 'per agent',
license_fee_display_name: 'Fee',
license_fee_amount: '1900',
monthly_credit_amount: 10_000,
rate_card_display_name: 'Rates',
metered_item_display_name: 'Prompt',
rate_value: 1,
config_key: 'STRIPE_STARTUP_PLAN_ID'
},
{
cpu_display_name: 'Credits',
cpu_lookup_key: 'cpu_credits',
meter_display_name: 'Cw meter',
meter_event_name: 'chatwoot.usage',
plan_display_name: 'Chatwoot Business',
plan_lookup_key: 'chatwoot_business_v2',
lookup_key: 'chatwoot_business_plan_license_fee_v2',
service_action_lookup_key: 'chatwoot_business_plan_v2_credits',
metered_item_lookup_key: 'chatwoot_business_plan_v2_usage',
licensed_item_display_name: 'Seat',
licensed_item_unit_label: 'per agent',
license_fee_display_name: 'Fee',
license_fee_amount: '3900',
monthly_credit_amount: 50_000,
rate_card_display_name: 'Rates',
metered_item_display_name: 'Prompt',
rate_value: 1,
config_key: 'STRIPE_BUSINESS_PLAN_ID'
},
{
cpu_display_name: 'Credits',
cpu_lookup_key: 'cpu_credits',
meter_display_name: 'Cw meter',
meter_event_name: 'chatwoot.usage',
plan_display_name: 'Chatwoot Enterprise',
plan_lookup_key: 'chatwoot_enterprise_plan_v2',
lookup_key: 'chatwoot_enterprise_license_fee_v2',
service_action_lookup_key: 'chatwoot_enterprise_plan_v2_credits',
metered_item_lookup_key: 'chatwoot_enterprise_plan_v2_usage',
licensed_item_display_name: 'Seat',
licensed_item_unit_label: 'per agent',
license_fee_display_name: 'Fee',
license_fee_amount: '9900',
monthly_credit_amount: 200_000,
rate_card_display_name: 'Rates',
metered_item_display_name: 'Prompt',
rate_value: 1,
config_key: 'STRIPE_ENTERPRISE_PLAN_ID'
}
]
service = Enterprise::Billing::V2::PricingPlanService.new(account: account)
puts 'Creating pricing plans...'
puts ''
configs.each do |config|
begin
result = service.create_complete_pricing_plan(config)
rescue StandardError => e
result = { success: false, error: e.message }
puts "Exception occurred: #{e.class}"
puts "Message: #{e.message}"
puts e.backtrace.first(5).join("\n")
end
if result[:success]
# Save or update the pricing plan ID in InstallationConfig
installation_config = InstallationConfig.find_or_initialize_by(name: config[:config_key])
installation_config.value = result[:pricing_plan].id
installation_config.save!
puts "✓ Created #{config[:plan_display_name]}"
puts " Plan ID: #{result[:pricing_plan].id}"
puts " CPU ID: #{result[:custom_pricing_unit].id}"
puts " Meter ID: #{result[:meter].id}"
else
puts "✗ Failed to create #{config[:plan_display_name]}"
puts " Error: #{result[:error]}" if result[:error]
end
puts ''
end
puts 'Summary:'
puts '--------'
puts "CPU ID saved to: STRIPE_CUSTOM_PRICING_UNIT_ID = #{InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value}"
puts "Meter ID saved to: STRIPE_METER_ID = #{InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value}"
puts ''
configs.each do |config|
plan_id = InstallationConfig.find_by(name: config[:config_key])&.value
puts "#{config[:config_key]} = #{plan_id}"
end
@@ -64,10 +64,78 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
id: @account.id,
monthly_credits: balance[:monthly],
topup_credits: balance[:topup],
total_credits: balance[:total]
total_credits: balance[:total],
usage_this_month: balance[:usage_this_month],
usage_total: balance[:usage_total]
}
end
def v2_pricing_plans
plans = Enterprise::Billing::V2::PlanCatalog.plans
render json: { pricing_plans: plans }
end
def v2_topup_options
options = Enterprise::Billing::V2::TopupCatalog.options
render json: { topup_options: options }
end
def v2_topup
amount = params[:credits].to_i
return render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity if amount <= 0
service = Enterprise::Billing::V2::TopupService.new(account: @account)
result = service.create_topup(credits: amount)
if result[:success]
render json: { success: true, message: result[:message] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def v2_subscribe
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
result = service.create_subscription_checkout(
pricing_plan_id: params[:pricing_plan_id],
quantity: subscription_quantity
)
if result[:success]
render json: { success: true, redirect_url: result[:redirect_url], checkout_session_id: result[:checkout_session_id] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def cancel_subscription
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
result = service.cancel_subscription
if result[:success]
render json: {
success: true,
message: result[:message],
cancel_at_period_end: result[:cancel_at_period_end],
period_end: result[:period_end]
}
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def update_subscription
service = Enterprise::Billing::V2::UpdateSubscriptionService.new(account: @account)
result = service.update_subscription(pricing_plan_id: params[:pricing_plan_id], quantity: params[:quantity])
if result[:success]
render json: result
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
private
def check_cloud_env
@@ -126,6 +194,11 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
render json: { redirect_url: redirect_url }
end
def subscription_quantity
quantity = params[:quantity].to_i
quantity.positive? ? quantity : 1
end
def pundit_user
{
user: current_user,
@@ -6,13 +6,15 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
# Determine which webhook secret to use based on event type
webhook_secret = determine_webhook_secret(payload)
event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
# Check if this is a V2 billing event
if v2_billing_event?(event)
handle_v2_event(event)
else
# Handle V1 events with existing service
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
end
# If we fail to verify the signature, then something was wrong with the request
@@ -28,33 +30,64 @@ class Enterprise::Webhooks::StripeController < ActionController::API
private
def determine_webhook_secret(payload)
# Parse the payload to check event type without full verification
parsed_payload = JSON.parse(payload)
event_type = parsed_payload['type']
# Use V2 webhook secret for V2 events, V1 secret for everything else
if event_type&.start_with?('v2.')
ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil)
else
ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)
end
end
def v2_billing_event?(event)
%w[
billing.credit_grant.created
billing.credit_grant.updated
].include?(event.type)
event.type.start_with?('v2.')
end
def handle_v2_event(event)
customer_id = extract_customer_id(event)
return if customer_id.blank?
account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id)
account = find_account_for_v2_event(event)
return unless account && account_v2_enabled?(account)
service = ::Enterprise::Billing::V2::WebhookHandlerService.new(account: account)
service.process(event)
end
def extract_customer_id(event)
data = event.data.object
def find_account_for_v2_event(event)
related_object = event.related_object
subscription_id = related_object.id
# Credit grants have customer field
if data.respond_to?(:customer)
data.customer
elsif data.respond_to?(:[])
data['customer']
customer_id = fetch_customer_id_from_subscription(subscription_id)
if customer_id.present?
account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id)
return account if account
end
Rails.logger.warn "Could not find account for subscription #{subscription_id}"
nil
end
def fetch_customer_id_from_subscription(subscription_id)
# Step 1: Fetch subscription
subscription = StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
return nil unless subscription&.billing_cadence
# Step 2: Fetch billing cadence
cadence = StripeV2Client.request(
:get,
"/v2/billing/cadences/#{subscription.billing_cadence}",
{},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
# Step 3: Extract customer from payer
cadence.payer&.customer
end
def account_v2_enabled?(_account)
@@ -2,19 +2,7 @@ class Enterprise::CreateStripeCustomerJob < ApplicationJob
queue_as :default
def perform(account)
# 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'
# Use V1 service - creates customer and stores customer_id
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
end
@@ -20,6 +20,15 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
end
def generate_response(input)
# Check and use credits before making API call
credit_result = check_and_use_credits
unless credit_result[:success]
error_message = credit_result[:message] || 'Insufficient credits'
Rails.logger.warn("#{self.class.name} Credit check failed for account #{@account.id}: #{error_message}")
return { error: error_message, error_code: 402 }
end
@messages << { role: 'user', content: input } if input.present?
response = request_chat_completion
@@ -113,4 +122,17 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
message_type: message_type
)
end
def check_and_use_credits
credit_service = Enterprise::Ai::CaptainCreditService.new(account: @account)
credit_service.check_and_use_credits(
feature: 'ai_copilot',
amount: 1,
metadata: {
'assistant_id' => @assistant.id,
'user_id' => @user&.id,
'copilot_thread_id' => @copilot_thread&.id
}
)
end
end
@@ -0,0 +1,95 @@
# frozen_string_literal: true
module Enterprise::Billing::Concerns::PlanFeatureManager
extend ActiveSupport::Concern
# Plan hierarchy: Hacker (default) -> Startup -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
# Basic features available starting with the Startup plan
STARTUP_PLAN_FEATURES = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
def update_plan_features(plan_name)
if plan_name.blank? || plan_name == 'Hacker'
disable_all_premium_features
else
enable_features_for_current_plan(plan_name)
end
# Enable any manually managed features configured in internal_attributes
enable_account_manually_managed_features
account.save!
end
def disable_all_premium_features
# Disable all features (for default Hacker plan or during plan changes)
account.disable_features(*STARTUP_PLAN_FEATURES)
account.disable_features(*BUSINESS_PLAN_FEATURES)
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
end
def enable_features_for_current_plan(plan_name)
# First disable all premium features to handle downgrades
disable_all_premium_features
# Then enable features based on the current plan
enable_plan_specific_features(plan_name)
end
def enable_plan_specific_features(plan_name)
return if plan_name.blank?
# Enable features based on plan hierarchy
case plan_name
when 'Startup', 'Startups'
# Startup plan gets the basic features
account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
# Business plan gets Startup features + Business features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
when 'Enterprise'
# Enterprise plan gets all features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
end
end
def reset_captain_usage
account.reset_response_usage if account.respond_to?(:reset_response_usage)
end
private
def enable_account_manually_managed_features
# Get manually managed features from internal attributes using the service
return unless defined?(Internal::Accounts::InternalAttributesService)
service = Internal::Accounts::InternalAttributesService.new(account)
features = service.manually_managed_features
# Enable each feature
account.enable_features(*features) if features.present?
rescue StandardError
# Silently handle errors - account will continue with plan-specific features
end
end
@@ -1,69 +1,33 @@
class Enterprise::Billing::CreateStripeCustomerService
pattr_initialize [:account!]
DEFAULT_QUANTITY = 2
def perform
return if existing_subscription?
return if customer_exists?
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
customer: customer_id,
items: [{ price: price_id, quantity: default_quantity }]
}
)
account.update!(
custom_attributes: {
stripe_customer_id: customer_id,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: default_plan['name'],
subscribed_quantity: subscription['quantity']
}
)
customer_id = create_customer
save_customer_id(customer_id)
end
private
def prepare_customer_id
customer_id = account.custom_attributes['stripe_customer_id']
if customer_id.blank?
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
customer_id = customer.id
end
customer_id
def customer_exists?
account.custom_attributes['stripe_customer_id'].present?
end
def default_quantity
default_plan['default_quantity'] || DEFAULT_QUANTITY
def create_customer
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
customer.id
end
def save_customer_id(customer_id)
account.update!(
custom_attributes: {
stripe_customer_id: customer_id
}
)
end
def billing_email
account.administrators.first.email
end
def default_plan
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
@default_plan ||= installation_config.value.first
end
def price_id
price_ids = default_plan['price_ids']
price_ids.first
end
def existing_subscription?
stripe_customer_id = account.custom_attributes['stripe_customer_id']
return false if stripe_customer_id.blank?
subscriptions = Stripe::Subscription.list(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
subscriptions.data.present?
end
end
@@ -1,29 +1,8 @@
class Enterprise::Billing::HandleStripeEventService
include Enterprise::Billing::Concerns::PlanFeatureManager
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
# Basic features available starting with the Startups plan
STARTUP_PLAN_FEATURES = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
def perform(event:)
@event = event
@@ -32,8 +11,10 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
when 'billing.credit_grant.created'
process_credit_grant_created
when 'billing.credit_grant.updated'
process_credit_grant_updated
end
end
@@ -46,7 +27,8 @@ class Enterprise::Billing::HandleStripeEventService
return if plan.blank? || account.blank?
update_account_attributes(subscription, plan)
update_plan_features
plan_name = account.custom_attributes['plan_name']
update_plan_features(plan_name)
reset_captain_usage
end
@@ -72,65 +54,22 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def update_plan_features
if default_plan?
disable_all_premium_features
else
enable_features_for_current_plan
end
# Enable any manually managed features configured in internal_attributes
enable_account_manually_managed_features
account.save!
end
def disable_all_premium_features
# Disable all features (for default Hacker plan)
account.disable_features(*STARTUP_PLAN_FEATURES)
account.disable_features(*BUSINESS_PLAN_FEATURES)
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
end
def enable_features_for_current_plan
# First disable all premium features to handle downgrades
disable_all_premium_features
# Then enable features based on the current plan
enable_plan_specific_features
end
def reset_captain_usage
account.reset_response_usage
end
def enable_plan_specific_features
plan_name = account.custom_attributes['plan_name']
return if plan_name.blank?
# Enable features based on plan hierarchy
case plan_name
when 'Startups'
# Startups plan gets the basic features
account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
# Business plan gets Startups features + Business features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
when 'Enterprise'
# Enterprise plan gets all features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
end
end
def subscription
@subscription ||= @event.data.object
end
def account
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
@account ||= begin
customer_id = if @event.type.start_with?('billing.credit_grant')
# Credit grant events have customer directly on the object
@event.data.object.respond_to?(:customer) ? @event.data.object.customer : @event.data.object['customer']
else
# Subscription events have customer on subscription
subscription.customer
end
Account.where("custom_attributes->>'stripe_customer_id' = ?", customer_id).first
end
end
def find_plan(plan_id)
@@ -144,12 +83,82 @@ class Enterprise::Billing::HandleStripeEventService
account.custom_attributes['plan_name'] == default_plan['name']
end
def enable_account_manually_managed_features
# Get manually managed features from internal attributes using the service
service = Internal::Accounts::InternalAttributesService.new(account)
features = service.manually_managed_features
def process_credit_grant_created
grant_id = extract_credit_grant_id(@event.data.object)
return if grant_id.blank?
# Enable each feature
account.enable_features(*features) if features.present?
# Retrieve the full credit grant object from Stripe API
grant = retrieve_credit_grant(grant_id)
return if grant.blank?
amount = extract_credit_amount(grant)
return if amount.zero?
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
if grant.expires_at.present?
service.sync_monthly_credits(amount)
else
service.add_topup_credits(amount)
end
end
def process_credit_grant_updated
grant = @event.data.object
# Check if grant has expired
return unless grant.respond_to?(:expired_at) && grant.expired_at
# Grant has expired
handle_credit_grant_expired
# Other updates (voided, amount changes, etc) - do nothing
end
def handle_credit_grant_expired
Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits
end
def extract_credit_grant_id(grant_object)
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
end
def retrieve_credit_grant(grant_id)
StripeV2Client.request(:get, "/v1/billing/credit_grants/#{grant_id}")
end
def extract_credit_amount(grant)
# First, try to get credits from metadata
metadata = extract_attribute(grant, :metadata)
if metadata
credits = extract_attribute(metadata, :credits)
return credits.to_i if credits.present? && credits.to_i.positive?
end
# Fallback: extract from amount object
amount_data = extract_attribute(grant, :amount)
return 0 unless amount_data
amount_type = extract_attribute(amount_data, :type)
case amount_type
when 'monetary'
extract_amount_value(amount_data, :monetary)
when 'custom_pricing_unit'
extract_amount_value(amount_data, :custom_pricing_unit)
else
0
end
end
def extract_attribute(object, attribute)
object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s]
end
def extract_amount_value(amount_data, unit_type)
unit = extract_attribute(amount_data, unit_type)
return 0 unless unit
value = extract_attribute(unit, :value)
value.to_i
end
end
@@ -0,0 +1,85 @@
class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
# Cancel subscription at period end using Stripe's V1 API
# Subscription remains active until the end of the current billing period
# Customer receives no refund/credit for remaining time
#
# @return [Hash] { success:, cancel_at_period_end:, period_end:, message: }
#
def cancel_subscription
return { success: false, message: 'Not a V2 billing account' } unless v2_enabled?
return { success: false, message: 'No active subscription' } unless active_subscription?
with_locked_account do
subscription_data = cancel_at_period_end
update_account_status(subscription_data)
success_response = build_success_response(subscription_data)
success_response
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
rescue StandardError => e
{ success: false, message: "Cancellation error: #{e.message}" }
end
private
def cancel_at_period_end
subscription_id = custom_attribute('stripe_subscription_id')
raise StandardError, 'No subscription ID found' if subscription_id.blank?
# Build update parameters to cancel at period end
params = {
cancel_at_period_end: true
}
StripeV2Client.request(
:post,
"/v1/subscriptions/#{subscription_id}",
params,
stripe_api_options
)
end
def update_account_status(subscription_data)
# Extract period end from subscription data
current_period_end = extract_attribute(subscription_data, :current_period_end)
period_end_time = current_period_end ? Time.zone.at(current_period_end).iso8601 : nil
# Mark subscription as cancelling (will be cancelled at period end)
update_custom_attributes({
'subscription_status' => 'cancel_at_period_end',
'subscription_cancelled_at' => Time.current.iso8601,
'subscription_period_end' => period_end_time
})
end
def build_success_response(subscription_data)
current_period_end = extract_attribute(subscription_data, :current_period_end)
period_end_time = current_period_end ? Time.zone.at(current_period_end) : nil
{
success: true,
cancel_at_period_end: true,
period_end: period_end_time,
message: 'Subscription will be cancelled at the end of the current billing period.'
}
end
def v2_enabled?
custom_attribute('stripe_billing_version')&.to_i == 2
end
def active_subscription?
custom_attribute('subscription_status') == 'active'
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
end
end
@@ -0,0 +1,140 @@
# V2 Billing Checkout Service
#
# This service creates Checkout Sessions for V2 Billing subscriptions using
# checkout_items with pricing_plan_subscription_item.
#
class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
# Create a subscription checkout session
#
# Creates a Checkout Session with checkout_items containing the V2 Pricing Plan
# and component configurations for the license fee.
#
# @param pricing_plan_id [String] V2 Pricing Plan ID
# @param quantity [Integer] Number of licenses/seats
# @return [Hash] { success:, session_id:, redirect_url: } or error
#
def create_subscription_checkout(pricing_plan_id:, quantity: 1)
@pricing_plan_id = pricing_plan_id
@quantity = quantity.to_i.positive? ? quantity.to_i : 1
base_url = ENV.fetch('FRONTEND_URL')
@success_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing?session_id={CHECKOUT_SESSION_ID}"
@cancel_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing"
validate_params
store_pending_subscription_quantity
create_checkout_session
rescue Stripe::StripeError => e
{ success: false, message: "Stripe API error: #{e.message}", error: e }
rescue StandardError => e
{ success: false, message: "Checkout error: #{e.message}", error: e }
end
private
def validate_params
raise StandardError, 'Pricing Plan ID required' if @pricing_plan_id.blank?
customer_id = custom_attribute('stripe_customer_id')
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if customer_id.blank?
end
def store_pending_subscription_quantity
# Store quantity in custom_attributes for webhook to use
# This is more reliable than extracting from subscription component_values
update_custom_attributes({
'pending_subscription_quantity' => @quantity,
'pending_subscription_pricing_plan' => @pricing_plan_id
})
end
# Create Checkout Session with checkout_items
#
# Uses V2 Pricing Plans directly via checkout_items parameter.
# This creates a subscription with V2 Billing features automatically.
#
def create_checkout_session
customer_id = custom_attribute('stripe_customer_id')
session = StripeV2Client.request(
:post,
'/v1/checkout/sessions',
checkout_session_params(customer_id),
stripe_api_options
)
build_success_response(session)
end
def checkout_session_params(customer_id)
{
customer: customer_id,
checkout_items: build_checkout_items,
automatic_tax: {
enabled: true
},
customer_update: {
address: 'auto',
shipping: 'auto'
},
success_url: @success_url,
cancel_url: @cancel_url,
metadata: session_metadata
}
end
def build_checkout_items
lookup_key = extract_license_lookup_key
raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key
[
{
type: 'pricing_plan_subscription_item',
pricing_plan_subscription_item: {
pricing_plan: @pricing_plan_id,
component_configurations: {
lookup_key => {
type: 'license_fee_component',
license_fee_component: {
quantity: @quantity
}
}
}
}
}
]
end
def extract_license_lookup_key
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id)
end
def session_metadata
{
account_id: account.id,
pricing_plan_id: @pricing_plan_id,
quantity: @quantity,
billing_version: 'v2'
}
end
def build_success_response(session)
session_id = session.respond_to?(:id) ? session.id : session['id']
session_url = session.respond_to?(:url) ? session.url : session['url']
{
success: true,
session_id: session_id,
redirect_url: session_url
}
end
def stripe_api_options
{
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil),
stripe_version: '2025-08-27.preview;checkout_product_catalog_preview=v1'
}
end
end
@@ -25,7 +25,6 @@ module Enterprise::Billing::V2::Concerns::PaymentIntentHandler
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
Rails.logger.info("Created payment intent: #{payment_intent.id} for amount: #{amount_due}")
payment_intent.id
end
@@ -25,7 +25,7 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
end
end
def sync_monthly_credits(amount)
def sync_monthly_credits(amount, metadata: {})
with_locked_account do
update_credits(monthly: amount)
if amount.positive?
@@ -33,20 +33,22 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
type: 'grant',
amount: amount,
credit_type: 'monthly',
description: 'Monthly credits from Stripe'
description: 'Monthly credits from Stripe',
metadata: metadata
)
end
end
end
def add_topup_credits(amount)
def add_topup_credits(amount, metadata: {})
with_locked_account do
update_credits(topup: topup_credits + amount)
log_credit_transaction(
type: 'topup',
amount: amount,
credit_type: 'topup',
description: 'Topup credits added'
description: 'Topup credits added',
metadata: metadata
)
end
end
@@ -60,10 +62,33 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
end
def credit_balance
usage_stats = calculate_usage_stats
{
monthly: monthly_credits,
topup: topup_credits,
total: total_credits
total: total_credits,
usage_this_month: usage_stats[:this_month],
usage_total: usage_stats[:total]
}
end
def calculate_usage_stats
month_start = Time.current.beginning_of_month
this_month_usage = account.credit_transactions
.where(transaction_type: 'use', created_at: month_start..Time.current)
.sum(:amount)
.abs
total_usage = account.credit_transactions
.where(transaction_type: 'use')
.sum(:amount)
.abs
{
this_month: this_month_usage,
total: total_usage
}
end
@@ -1,68 +0,0 @@
# 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
@@ -0,0 +1,113 @@
module Enterprise::Billing::V2::PlanCatalog
DEFAULT_CURRENCY = 'usd'.freeze
CREDIT_UNIT = 'Credits'.freeze
PLAN_DEFINITIONS = [
{
key: :free,
display_name: 'Chatwoot Hacker',
base_fee: 0.0,
monthly_credits: 0,
config_key: 'STRIPE_HACKER_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_hacker_license_fee_v2'
},
{
key: :startup,
display_name: 'Chatwoot Startup',
base_fee: 19.0,
monthly_credits: 10_000,
config_key: 'STRIPE_STARTUP_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2'
},
{
key: :business,
display_name: 'Chatwoot Business',
base_fee: 39.0,
monthly_credits: 50_000,
config_key: 'STRIPE_BUSINESS_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_business_license_fee_v2'
},
{
key: :enterprise,
display_name: 'Chatwoot Enterprise',
base_fee: 99.0,
monthly_credits: 200_000,
config_key: 'STRIPE_ENTERPRISE_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_enterprise_license_fee_v2'
}
].freeze
module_function
def plans
PLAN_DEFINITIONS.map do |definition|
plan_id = plan_id_for(definition)
build_plan(definition, plan_id)
end
end
def definition_for(plan_id)
PLAN_DEFINITIONS.each do |definition|
return definition if plan_id_for(definition) == plan_id
end
nil
end
def monthly_credits_for(plan_id)
definition = definition_for(plan_id)
definition ? definition[:monthly_credits] : nil
end
def plan_id_for(definition)
InstallationConfig.find_by(name: definition[:config_key])&.value
end
def lookup_key_for_plan(plan_id)
# Returns the licensed_item_lookup_key for checkout sessions
definition = definition_for(plan_id)
definition&.dig(:licensed_item_lookup_key)
end
def build_plan(definition, plan_id)
{
id: plan_id,
display_name: definition[:display_name],
currency: DEFAULT_CURRENCY,
tax_behavior: 'exclusive',
components: build_components(definition)
}
end
def build_components(definition)
components = [service_action_component(definition), rate_card_component(definition)]
components << license_fee_component(definition) if definition[:base_fee]&.positive?
components
end
def service_action_component(definition)
{
type: 'service_action',
name: 'Monthly Credits',
credit_amount: definition[:monthly_credits],
credit_unit: CREDIT_UNIT
}
end
def rate_card_component(definition)
{
type: 'rate_card',
name: 'Overage Rate',
overage_rate: definition[:overage_rate],
rate_unit: CREDIT_UNIT,
meter_id: nil
}
end
def license_fee_component(definition)
{
type: 'license_fee',
name: 'Base Fee',
unit_amount: definition[:base_fee].round(2)
}
end
end
@@ -2,20 +2,22 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
def add_license_fee_component(plan, config)
licensed_item = create_licensed_item(
display_name: config[:licensed_item_display_name],
lookup_key: config[:licensed_item_lookup_key],
lookup_key: config[:lookup_key],
unit_label: config[:licensed_item_unit_label]
)
license_fee = create_license_fee(
display_name: config[:license_fee_display_name],
unit_amount: config[:license_fee_amount],
licensed_item_id: licensed_item.id
licensed_item_id: licensed_item.id,
lookup_key: config[:lookup_key]
)
add_component(
plan_id: plan.id,
type: 'license_fee',
data: { id: license_fee.id, version: license_fee.latest_version }
data: { id: license_fee.id, version: license_fee.latest_version },
lookup_key: config[:lookup_key]
)
end
@@ -29,7 +31,8 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
add_component(
plan_id: plan.id,
type: 'service_action',
data: { id: action.id }
data: { id: action.id },
lookup_key: config[:service_action_lookup_key]
)
action
@@ -49,7 +52,8 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
add_component(
plan_id: plan.id,
type: 'rate_card',
data: { id: card.id, version: card.latest_version }
data: { id: card.id, version: card.latest_version },
lookup_key: config[:metered_item_lookup_key]
)
card
@@ -66,7 +70,7 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
)
end
def create_license_fee(display_name:, unit_amount:, licensed_item_id:)
def create_license_fee(display_name:, unit_amount:, licensed_item_id:, lookup_key:)
StripeV2Client.request(
:post,
'/v2/billing/license_fees',
@@ -76,8 +80,9 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
service_interval: 'month',
service_interval_count: 1,
tax_behavior: 'exclusive',
unit_amount: unit_amount,
licensed_item: licensed_item_id
unit_amount: unit_amount.to_s,
licensed_item: licensed_item_id,
lookup_key: lookup_key
},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
@@ -154,14 +159,14 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing
)
end
def add_component(plan_id:, type:, data:)
def add_component(plan_id:, type:, data:, lookup_key:)
params = case type
when 'license_fee'
{ type: 'license_fee', license_fee: data }
{ type: 'license_fee', license_fee: data, lookup_key: lookup_key }
when 'service_action'
{ type: 'service_action', service_action: data }
{ type: 'service_action', service_action: data, lookup_key: lookup_key }
when 'rate_card'
{ type: 'rate_card', rate_card: data }
{ type: 'rate_card', rate_card: data, lookup_key: lookup_key }
end
StripeV2Client.request(
@@ -25,11 +25,11 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
)
end
def create_pricing_plan(display_name:, currency: 'usd', tax_behavior: 'exclusive')
def create_pricing_plan(display_name:, lookup_key:, currency: 'usd', tax_behavior: 'exclusive')
StripeV2Client.request(
:post,
'/v2/billing/pricing_plans',
{ display_name: display_name, currency: currency, tax_behavior: tax_behavior },
{ display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
end
@@ -39,35 +39,37 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
cpu = get_or_create_cpu(config)
meter = get_or_create_meter(config)
plan = create_pricing_plan(display_name: config[:plan_display_name])
plan = create_pricing_plan(display_name: config[:plan_display_name], lookup_key: config[:plan_lookup_key])
builder = component_builder
builder.add_license_fee_component(plan, config) if config[:include_license_fee]
builder.add_license_fee_component(plan, config)
service_action = builder.add_service_action_component(plan, config, cpu)
rate_card = builder.add_rate_card_component(plan, config, meter, cpu)
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 }
# Make the latest version live
make_plan_version_live(plan.id)
build_plan_result(plan, cpu, meter, rate_card, service_action)
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)
shared_cpu_id = InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value
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(
cpu = create_custom_pricing_unit(
display_name: config[:cpu_display_name],
lookup_key: config[:cpu_lookup_key]
)
installation_config = InstallationConfig.find_or_initialize_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')
installation_config.value = cpu.id
installation_config.save!
cpu
end
end
@@ -81,10 +83,16 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
OpenStruct.new(id: shared_meter_id)
else
# Create new meter if not using shared
create_meter(
meter = create_meter(
display_name: config[:meter_display_name],
event_name: config[:meter_event_name]
)
installation_config = InstallationConfig.find_or_initialize_by(name: 'STRIPE_METER_ID')
installation_config.value = meter.id
installation_config.save!
meter
end
end
@@ -94,6 +102,15 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas
@component_builder ||= Enterprise::Billing::V2::PricingPlanComponentBuilder.new(account: account)
end
def make_plan_version_live(plan_id)
StripeV2Client.request(
:post,
"/v2/billing/pricing_plans/#{plan_id}",
{ live_version: 'latest' },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
end
def build_plan_result(plan, cpu, meter, rate_card, service_action)
{
success: true,
@@ -1,322 +0,0 @@
# 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
# 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
rescue Stripe::StripeError => e
{ success: false, message: "Stripe API error: #{e.message}", error: e }
rescue StandardError => e
{ success: false, message: "Subscription error: #{e.message}", error: e }
end
private
def validate_subscription_params
return { success: false, message: 'Customer ID required' } if @customer_id.blank?
return { success: false, message: 'Pricing Plan ID required' } if @pricing_plan_id.blank?
nil
end
def execute_subscription_flow
with_locked_account do
cadence = create_billing_cadence
return { success: false, message: 'Failed to create billing cadence' } unless cadence
pricing_plan = pricing_plan_details
return { success: false, message: 'Failed to get pricing plan details' } unless pricing_plan
intent = create_billing_intent(cadence.id, pricing_plan)
return { success: false, message: 'Failed to create billing intent' } unless intent
reserve_and_commit_intent(intent.id)
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
end
def reserve_and_commit_intent(intent_id)
reserved_intent = reserve_intent(intent_id)
return { success: false, message: 'Failed to reserve intent' } unless reserved_intent
committed_intent = commit_intent(intent_id)
return { success: false, message: 'Failed to commit intent' } unless committed_intent
committed_intent
end
def build_subscription_result(cadence_id, intent_id)
{
success: true,
customer_id: @customer_id,
pricing_plan_id: @pricing_plan_id,
cadence_id: cadence_id,
intent_id: intent_id,
status: 'subscribed'
}
end
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
def create_billing_cadence
cadence_params = {
payer: {
type: 'customer',
customer: @customer_id
},
billing_cycle: {
type: 'month',
interval_count: 1,
month: {
day_of_month: 1
}
}
}
StripeV2Client.request(
:post,
'/v2/billing/cadences',
cadence_params,
stripe_api_options
)
end
def pricing_plan_details
StripeV2Client.request(
:get,
"/v2/billing/pricing_plans/#{@pricing_plan_id}",
{},
stripe_api_options
)
end
def create_billing_intent(cadence_id, pricing_plan)
plan_version = extract_plan_version(pricing_plan)
intent_params = build_intent_params(cadence_id, plan_version)
StripeV2Client.request(
:post,
'/v2/billing/intents',
intent_params,
stripe_api_options
)
end
def extract_plan_version(pricing_plan)
pricing_plan['latest_version'] || pricing_plan['live_version'] || pricing_plan['version']
end
def build_intent_params(cadence_id, plan_version)
{
currency: 'usd',
cadence: cadence_id,
actions: [build_subscription_action(plan_version)]
}
end
def build_subscription_action(plan_version)
{
type: 'subscribe',
subscribe: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: {
pricing_plan: @pricing_plan_id,
pricing_plan_version: plan_version,
component_configurations: []
}
}
}
end
def reserve_intent(intent_id)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{intent_id}/reserve",
{},
stripe_api_options
)
end
def commit_intent(intent_id)
intent = fetch_billing_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 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,
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
return if customer.invoice_settings&.default_payment_method.present?
# In production, payment methods must be added via Checkout or SetupIntent
# This ensures proper customer authentication and PCI compliance
raise Stripe::StripeError,
'Payment method required. Customer must add payment method via Stripe Checkout or SetupIntent before subscribing.'
end
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'
}
# Store meter configuration if provided
attributes['stripe_meter_id'] = @meter_id if @meter_id.present?
attributes['stripe_meter_event_name'] = @meter_event_name if @meter_event_name.present?
update_custom_attributes(attributes)
end
def extract_plan_name(pricing_plan)
display_name = pricing_plan['display_name'] || pricing_plan[:display_name]
return 'Business' unless display_name
# 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
@@ -0,0 +1,152 @@
class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService
# Features for each plan tier (matching the plan hierarchy)
STARTUP_FEATURES = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
].freeze
BUSINESS_FEATURES = (STARTUP_FEATURES + %w[sla custom_roles]).freeze
ENTERPRISE_FEATURES = (BUSINESS_FEATURES + %w[audit_logs disable_branding saml]).freeze
def provision(subscription_id:)
# Retrieve pricing plan subscription details from Stripe V2 API
subscription = retrieve_pricing_plan_subscription(subscription_id)
# Extract details from the subscription
pricing_plan_id = extract_pricing_plan_id(subscription)
quantity = extract_subscription_quantity(subscription)
# Update account with subscription details
update_subscription_details(subscription_id, pricing_plan_id, quantity)
# Provision the subscription: sync credits and enable features
provision_subscription(pricing_plan_id) if pricing_plan_id.present?
build_success_response(subscription_id, pricing_plan_id, quantity)
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
rescue StandardError => e
{ success: false, message: "Provisioning error: #{e.message}" }
end
private
def build_success_response(subscription_id, pricing_plan_id, quantity)
{
success: true,
subscription_id: subscription_id,
pricing_plan_id: pricing_plan_id,
quantity: quantity,
message: 'Subscription provisioned successfully'
}
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def extract_pricing_plan_id(subscription)
# Extract pricing_plan from the subscription object
pricing_plan = subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan']
pricing_plan.is_a?(String) ? pricing_plan : pricing_plan&.[]('id') || pricing_plan&.dig(:id)
end
def extract_subscription_quantity(_subscription)
# Get quantity from account custom_attributes (set during checkout)
pending_quantity = account.custom_attributes['pending_subscription_quantity']
if pending_quantity.present? && pending_quantity.to_i.positive?
Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{pending_quantity}"
return pending_quantity.to_i
end
Rails.logger.warn '[V2 Billing] No pending_subscription_quantity found in custom_attributes, defaulting to quantity=1'
1
end
def update_subscription_details(subscription_id, pricing_plan_id, quantity)
Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \
"pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}"
attributes = {
'stripe_billing_version' => 2,
'stripe_subscription_id' => subscription_id,
'subscribed_quantity' => quantity,
'subscription_status' => 'active',
'pending_subscription_quantity' => nil,
'pending_subscription_pricing_plan' => nil
}
attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present?
# Add plan name from catalog
if pricing_plan_id.present?
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
end
update_custom_attributes(attributes)
end
def provision_subscription(pricing_plan_id)
# Sync monthly credits based on plan
sync_plan_credits(pricing_plan_id)
# Enable plan features based on plan
enable_plan_features(pricing_plan_id)
end
def sync_plan_credits(pricing_plan_id)
plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id)
return unless plan_credits
Enterprise::Billing::V2::CreditManagementService
.new(account: account)
.sync_monthly_credits(plan_credits.to_i)
end
def enable_plan_features(pricing_plan_id)
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
return unless plan_definition
plan_tier = extract_plan_tier(plan_definition)
return unless plan_tier
features_to_enable = features_for_plan_tier(plan_tier)
return if features_to_enable.empty?
# Enable each feature using the account method
account.enable_features(*features_to_enable)
end
def extract_plan_tier(plan_definition)
plan_definition[:display_name].split.find { |word| %w[Startup Business Enterprise].include?(word) }
end
def features_for_plan_tier(plan_tier)
case plan_tier
when 'Startup'
STARTUP_FEATURES
when 'Business'
BUSINESS_FEATURES
when 'Enterprise'
ENTERPRISE_FEATURES
else
[]
end
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end
@@ -0,0 +1,34 @@
module Enterprise::Billing::V2::TopupCatalog
DEFAULT_TOPUPS = [
{ credits: 100, amount: 5.0 },
{ credits: 400, amount: 10.0 },
{ credits: 1000, amount: 20.0 },
{ credits: 5000, amount: 50.0 }
].freeze
module_function
def options
custom_options = InstallationConfig.find_by(name: 'STRIPE_TOPUP_OPTIONS')&.value
parsed = parse_options(custom_options)
(parsed.presence || DEFAULT_TOPUPS).sort_by { |opt| opt[:credits] }.map do |option|
{
credits: option[:credits],
amount: option[:amount],
currency: option[:currency] || 'usd'
}
end
end
def parse_options(raw)
return [] if raw.blank?
JSON.parse(raw, symbolize_names: true)
rescue JSON::ParserError
[]
end
def find_option(credits)
options.find { |option| option[:credits].to_i == credits.to_i }
end
end
@@ -0,0 +1,172 @@
class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseService
def create_topup(credits:)
validation_result = validate_topup_request(credits)
return validation_result unless validation_result[:valid]
topup_definition = validation_result[:topup_definition]
amount_cents = (topup_definition[:amount] * 100).to_i
currency = topup_definition[:currency] || 'usd'
with_locked_account do
process_topup_transaction(credits, amount_cents, currency, topup_definition[:amount])
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
rescue StandardError => e
{ success: false, message: "Topup error: #{e.message}" }
end
private
def validate_topup_request(credits)
return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive?
topup_definition = Enterprise::Billing::V2::TopupCatalog.find_option(credits)
return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition
return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank?
{ valid: true, topup_definition: topup_definition }
end
def process_topup_transaction(credits, amount_cents, currency, amount)
invoice = create_topup_invoice(currency)
return { success: false, message: 'Failed to create invoice' } unless invoice
invoice_item = create_topup_invoice_item(invoice.id, amount_cents, currency, credits)
return { success: false, message: 'Failed to create invoice item' } unless invoice_item
finalized_invoice = finalize_topup_invoice(invoice.id)
return { success: false, message: 'Failed to finalize invoice' } unless finalized_invoice
paid_invoice = pay_invoice(invoice.id)
return { success: false, message: 'Failed to pay invoice' } unless paid_invoice
credit_grant = create_stripe_credit_grant(amount_cents, currency, credits)
return { success: false, message: 'Failed to create credit grant in Stripe' } unless credit_grant
# Credits will be added by webhook when Stripe sends billing.credit_grant.created event
build_success_response(credits, amount, currency, invoice.id, credit_grant['id'])
end
def build_success_response(credits, amount, currency, invoice_id, credit_grant_id)
{
success: true,
message: 'Top-up purchased successfully',
credits: credits,
amount: amount,
currency: currency,
invoice_id: invoice_id,
credit_grant_id: credit_grant_id
}
end
# Create Invoice following Stripe UBB Integration Guide
def create_topup_invoice(currency)
Stripe::Invoice.create(
{
customer: stripe_customer_id,
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false, # We'll finalize it manually
metadata: {
account_id: account.id.to_s,
topup: 'true'
}
},
stripe_api_options
)
end
# Create Invoice Item with topup amount
def create_topup_invoice_item(invoice_id, amount_cents, currency, credits)
Stripe::InvoiceItem.create(
{
customer: stripe_customer_id,
amount: amount_cents,
currency: currency,
invoice: invoice_id,
description: "Credit Topup: #{credits} credits",
metadata: {
account_id: account.id.to_s,
credits: credits.to_s,
topup: 'true'
}
},
stripe_api_options
)
end
# Finalize Invoice for payment
def finalize_topup_invoice(invoice_id)
Stripe::Invoice.finalize_invoice(
invoice_id,
{ auto_advance: false }, # We'll pay it explicitly
stripe_api_options
)
end
# Pay the invoice explicitly
def pay_invoice(invoice_id)
Stripe::Invoice.pay(
invoice_id,
{},
stripe_api_options
)
end
# Create Credit Grant in Stripe using monetary amount (not custom_pricing_unit)
# Following Stripe UBB Integration Guide section 8
def create_stripe_credit_grant(amount_cents, currency, credits)
Stripe::Billing::CreditGrant.create(
credit_grant_params(amount_cents, currency, credits),
stripe_api_options
)
end
def credit_grant_params(amount_cents, currency, credits)
{
customer: stripe_customer_id,
name: "Topup: #{credits} credits",
amount: credit_grant_amount(amount_cents, currency),
applicability_config: credit_grant_applicability,
category: 'paid',
metadata: credit_grant_metadata(credits)
}
end
def credit_grant_amount(amount_cents, currency)
{
type: 'monetary',
monetary: {
currency: currency,
value: amount_cents
}
}
end
def credit_grant_applicability
# Apply credit grant to all metered usage for this customer
# This ensures topup credits offset meter-based billing
{
scope: {
price_type: 'metered'
}
}
end
def credit_grant_metadata(credits)
{
account_id: account.id.to_s,
source: 'topup',
credits: credits.to_s
}
end
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end
@@ -0,0 +1,131 @@
class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
# Update subscription to a new plan with proration
#
# @param pricing_plan_id [String] New V2 Pricing Plan ID
# @param quantity [Integer] Number of licenses/seats
# @return [Hash] { success:, subscription_id:, prorated: } or error
#
def update_subscription(pricing_plan_id:, quantity: 1)
@pricing_plan_id = pricing_plan_id
@quantity = quantity.to_i.positive? ? quantity.to_i : 1
validate_params
store_pending_subscription_details
update_stripe_subscription
rescue Stripe::StripeError => e
{ success: false, message: "Stripe API error: #{e.message}", error: e }
rescue StandardError => e
{ success: false, message: "Update error: #{e.message}", error: e }
end
private
def validate_params
raise StandardError, 'Pricing Plan ID required' if @pricing_plan_id.blank?
raise StandardError, 'Not a V2 billing account' unless v2_enabled?
raise StandardError, 'No active subscription to update. Please subscribe to a plan first.' unless active_subscription?
end
def v2_enabled?
custom_attribute('stripe_billing_version')&.to_i == 2
end
def active_subscription?
# Both subscription_status and subscription_id must be present
custom_attribute('subscription_status') == 'active' &&
custom_attribute('stripe_subscription_id').present?
end
def store_pending_subscription_details
# Store for webhook to use when it fires
update_custom_attributes({
'pending_subscription_quantity' => @quantity,
'pending_subscription_pricing_plan' => @pricing_plan_id
})
end
def update_stripe_subscription
subscription_id = custom_attribute('stripe_subscription_id')
lookup_key = extract_license_lookup_key
raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key
# Get the current subscription to find the subscription item ID
current_subscription = fetch_current_subscription(subscription_id)
item_id = extract_subscription_item_id(current_subscription)
# Update the subscription with new price and quantity
updated_subscription = StripeV2Client.request(
:post,
"/v1/subscriptions/#{subscription_id}",
subscription_update_params(item_id, lookup_key),
stripe_api_options
)
build_success_response(updated_subscription)
end
def fetch_current_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v1/subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def extract_subscription_item_id(subscription)
# Get the first subscription item (we only have one item per subscription)
items = subscription.respond_to?(:items) ? subscription.items : subscription['items']
data = items.respond_to?(:data) ? items.data : items['data']
first_item = data&.first
raise StandardError, 'No subscription items found' unless first_item
first_item.respond_to?(:id) ? first_item.id : first_item['id']
end
def subscription_update_params(item_id, lookup_key)
price_id = "price_#{lookup_key}"
{
items: [
{
id: item_id,
price: price_id,
quantity: @quantity
}
],
proration_behavior: 'create_prorations',
metadata: {
account_id: account.id,
pricing_plan_id: @pricing_plan_id,
quantity: @quantity,
billing_version: 'v2'
}
}
end
def extract_license_lookup_key
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id)
end
def build_success_response(subscription)
subscription_id = subscription.respond_to?(:id) ? subscription.id : subscription['id']
{
success: true,
subscription_id: subscription_id,
prorated: true,
message: 'Subscription updated successfully with prorations'
}
end
def stripe_api_options
{
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil),
stripe_version: '2025-08-27.preview'
}
end
end
@@ -1,10 +1,10 @@
class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::BaseService
def process(event)
case event.type
when 'billing.credit_grant.created'
handle_credit_grant_created(event.data.object)
when 'billing.credit_grant.updated'
handle_credit_grant_updated(event.data.object)
when 'v2.billing.pricing_plan_subscription.servicing_activated'
Rails.logger.info "Handling subscription servicing activated event: #{event.related_object.id}"
Rails.logger.info "Related object: #{event.related_object.inspect}"
handle_subscription_servicing_activated(event.related_object.id)
else
{ success: true }
end
@@ -14,49 +14,9 @@ class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::
private
def handle_credit_grant_created(grant)
amount = extract_credit_amount(grant)
return { success: true } if amount.zero?
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
if grant.expires_at.present?
service.sync_monthly_credits(amount)
else
service.add_topup_credits(amount)
end
{ success: true }
end
def handle_credit_grant_updated(grant)
# Check if grant has expired
if grant.respond_to?(:expired_at) && grant.expired_at
# Grant has expired
handle_credit_grant_expired
else
# Other updates (voided, amount changes, etc)
{ success: true }
end
end
def handle_credit_grant_expired
Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits
{ success: true }
end
def extract_credit_amount(grant)
# Handle both Hash and OpenStruct response formats
amount_data = grant.respond_to?(:amount) ? grant.amount : grant['amount']
return 0 unless amount_data
# Extract value from nested structure
if amount_data.is_a?(Hash)
amount_data.dig('custom_pricing_unit', 'value').to_i
elsif amount_data.respond_to?(:custom_pricing_unit)
amount_data.custom_pricing_unit&.value.to_i
else
0
end
def handle_subscription_servicing_activated(subscription_id)
Enterprise::Billing::V2::SubscriptionProvisioningService
.new(account: account)
.provision(subscription_id: subscription_id)
end
end
+30 -1
View File
@@ -26,10 +26,13 @@ module StripeV2Client
when :post
req = Net::HTTP::Post.new(uri)
if v2_endpoint?(path)
# V2 endpoints use JSON
req.body = params.to_json unless params.empty?
req['Content-Type'] = 'application/json'
else
req.set_form_data(params) unless params.empty?
# V1 endpoints (including checkout sessions) use form data
# even with preview API versions
req.body = encode_nested_params(params) unless params.empty?
req['Content-Type'] = 'application/x-www-form-urlencoded'
end
req
@@ -42,6 +45,32 @@ module StripeV2Client
path.start_with?('/v2/')
end
# Encode nested parameters for form submission
# Stripe expects nested params like: checkout_items[0][type]=value
def encode_nested_params(params, prefix = nil)
pairs = []
params.each do |key, value|
full_key = prefix ? "#{prefix}[#{key}]" : key.to_s
pairs.concat(encode_param_value(full_key, value))
end
pairs.join('&')
end
def encode_param_value(key, value)
case value
when Hash
[encode_nested_params(value, key)]
when Array
value.each_with_index.map { |item, index| encode_nested_params(item, "#{key}[#{index}]") }
when true, false
["#{CGI.escape(key)}=#{value}"]
when nil
[]
else
["#{CGI.escape(key)}=#{CGI.escape(value.to_s)}"]
end
end
def parse_response(response)
body = JSON.parse(response.body)
@@ -12,41 +12,16 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do
.on_queue('default')
end
context 'when V1 billing' do
before do
allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('false')
end
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)
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)
perform_enqueued_jobs { job }
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
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account)
end
end
@@ -5,137 +5,37 @@ describe Enterprise::Billing::CreateStripeCustomerService do
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
describe '#perform' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).not_to have_received(:create)
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: 'cus_random_number',
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
it 'calls stripe methods to create a customer and updates the account' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: customer.id,
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
end
describe 'when checking for existing subscriptions' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
context 'when account has no stripe_customer_id' do
it 'creates a new subscription' do
context 'when customer does not exist' do
it 'creates a stripe customer and saves the customer_id' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create)
expect(Stripe::Subscription).to have_received(:create)
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: 'cus_random_number'
}.with_indifferent_access
)
end
end
context 'when account has stripe_customer_id' do
let(:stripe_customer_id) { 'cus_random_number' }
context 'when customer already exists' do
before do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_customer' })
end
context 'when customer has active subscriptions' do
before do
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
allow(Stripe::Subscription).to receive(:create)
end
it 'does not create a new customer' do
allow(Stripe::Customer).to receive(:create)
it 'does not create a new subscription' do
create_stripe_customer_service.new(account: account).perform
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Subscription).not_to have_received(:create)
expect(Stripe::Subscription).to have_received(:list).with(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
end
expect(Stripe::Customer).not_to have_received(:create)
expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_customer')
end
end
end
@@ -317,4 +317,160 @@ describe Enterprise::Billing::HandleStripeEventService do
end
end
end
describe 'credit grant handling' do
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
before do
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new)
.with(account: account).and_return(credit_service)
end
context 'when handling monthly credit grant' do
it 'syncs monthly credits from Stripe' do
allow(credit_service).to receive(:sync_monthly_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_123',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API (has complete amount structure)
api_grant_response = OpenStruct.new(
id: 'credgr_test_123',
customer: 'cus_123',
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 2000)
),
expires_at: Time.current
)
allow(StripeV2Client).to receive(:request)
.with(:get, '/v1/billing/credit_grants/credgr_test_123')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:sync_monthly_credits).with(2000)
end
end
context 'when handling topup credit grant' do
it 'adds topup credits' do
allow(credit_service).to receive(:add_topup_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_456',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API (has complete amount structure)
api_grant_response = OpenStruct.new(
id: 'credgr_test_456',
customer: 'cus_123',
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 500)
),
expires_at: nil
)
allow(StripeV2Client).to receive(:request)
.with(:get, '/v1/billing/credit_grants/credgr_test_456')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_topup_credits).with(500)
end
end
context 'when handling credit grant update with expiration' do
it 'expires monthly credits when grant is expired' do
allow(credit_service).to receive(:expire_monthly_credits).and_return(100)
grant = OpenStruct.new(
customer: 'cus_123',
expired_at: Time.current
)
allow(event).to receive(:type).and_return('billing.credit_grant.updated')
allow(data).to receive(:object).and_return(grant)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:expire_monthly_credits)
end
end
context 'when handling monetary type credit grant' do
it 'syncs monthly credits from monetary grant' do
allow(credit_service).to receive(:sync_monthly_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_monetary',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API with monetary amount
api_grant_response = OpenStruct.new(
id: 'credgr_test_monetary',
customer: 'cus_123',
amount: OpenStruct.new(
type: 'monetary',
monetary: OpenStruct.new(
currency: 'usd',
value: 1000
)
),
expires_at: Time.current
)
allow(StripeV2Client).to receive(:request)
.with(:get, '/v1/billing/credit_grants/credgr_test_monetary')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:sync_monthly_credits).with(1000)
end
end
context 'when handling credit grant with zero amount' do
it 'does not call credit service' do
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_zero',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API with zero amount
api_grant_response = OpenStruct.new(
id: 'credgr_test_zero',
customer: 'cus_123',
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 0)
),
expires_at: Time.current
)
allow(StripeV2Client).to receive(:request)
.with(:get, '/v1/billing/credit_grants/credgr_test_zero')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
# Ensure we don't accidentally call these methods
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
end
end
end
end
@@ -48,58 +48,88 @@ RSpec.describe Enterprise::Billing::V2::PricingPlanService do
plan_response = OpenStruct.new(id: 'plan_123')
allow(StripeV2Client).to receive(:request).and_return(plan_response)
result = service.create_pricing_plan(display_name: 'Business Plan')
result = service.create_pricing_plan(display_name: 'Business Plan', lookup_key: 'business_plan')
expect(result.id).to eq('plan_123')
expect(StripeV2Client).to have_received(:request).with(
:post,
'/v2/billing/pricing_plans',
{ display_name: 'Business Plan', currency: 'usd', tax_behavior: 'exclusive' },
{ display_name: 'Business Plan', currency: 'usd', tax_behavior: 'exclusive', lookup_key: 'business_plan' },
{ api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' }
)
end
end
describe '#create_complete_pricing_plan' do
# Clean up any existing InstallationConfig entries before each test
before do
InstallationConfig.where(name: %w[STRIPE_CUSTOM_PRICING_UNIT_ID STRIPE_METER_ID]).destroy_all
# Also ensure ENV doesn't have STRIPE_METER_ID
allow(ENV).to receive(:fetch).with('STRIPE_METER_ID', nil).and_return(nil)
end
let(:cpu) { OpenStruct.new(id: 'cpu_123') }
let(:meter) { OpenStruct.new(id: 'meter_123') }
let(:plan) { OpenStruct.new(id: 'plan_123') }
let(:service_action) { OpenStruct.new(id: 'sa_123') }
let(:rate_card) { OpenStruct.new(id: 'rc_123', latest_version: 'v1') }
let(:live_plan) { OpenStruct.new(id: 'plan_123', live_version: 'latest') }
let(:config) do
{
# Shared resources
cpu_display_name: 'Credits',
cpu_lookup_key: 'cpu_001',
meter_display_name: 'Prompts',
meter_event_name: 'prompts_001',
# Plan details
plan_display_name: 'Business Plan',
include_license_fee: true,
plan_lookup_key: 'business_plan',
# Component-specific lookup keys (each must be unique)
lookup_key: 'chatwoot_business_agents', # Used for license fee
service_action_lookup_key: 'chatwoot_business_credits',
metered_item_lookup_key: 'chatwoot_business_usage',
# License fee
licensed_item_display_name: 'Seat',
licensed_item_lookup_key: 'seat_001',
licensed_item_unit_label: 'per agent',
license_fee_display_name: 'Fee',
license_fee_amount: '3900',
service_action_lookup_key: 'credits_001',
license_fee_amount: 3900,
# Service action (monthly credits)
monthly_credit_amount: 2000,
# Rate card (overage)
rate_card_display_name: 'Rates',
metered_item_display_name: 'Prompt',
metered_item_lookup_key: 'prompt_001',
rate_value: 1
}
end
it 'creates a complete pricing plan with all components' do
cpu = OpenStruct.new(id: 'cpu_123')
meter = OpenStruct.new(id: 'meter_123')
plan = OpenStruct.new(id: 'plan_123')
service_action = OpenStruct.new(id: 'sa_123')
rate_card = OpenStruct.new(id: 'rc_123', latest_version: 'v1')
def stub_stripe_client
allow(StripeV2Client).to receive(:request) do |_method, path, *_args|
case path
when '/v2/billing/custom_pricing_units' then cpu
when '/v1/billing/meters' then meter
when '/v2/billing/pricing_plans' then plan
when '/v2/billing/pricing_plans/plan_123' then live_plan
end
end
end
allow(StripeV2Client).to receive(:request).and_return(cpu, meter, plan, service_action, rate_card)
# Create a double for the component builder
def stub_component_builder
component_builder = instance_double(Enterprise::Billing::V2::PricingPlanComponentBuilder)
allow(component_builder).to receive(:add_license_fee_component).and_return(true)
allow(component_builder).to receive(:add_service_action_component).and_return(service_action)
allow(component_builder).to receive(:add_rate_card_component).and_return(rate_card)
# Stub the private component_builder method to return our double
allow(service).to receive(:component_builder).and_return(component_builder)
end
it 'creates a complete pricing plan with all components' do
stub_stripe_client
stub_component_builder
result = service.create_complete_pricing_plan(config)
@@ -108,6 +138,8 @@ RSpec.describe Enterprise::Billing::V2::PricingPlanService do
expect(result[:custom_pricing_unit]).to eq(cpu)
expect(result[:meter]).to eq(meter)
expect(result[:service_action]).to eq(service_action)
expect(InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value).to eq('cpu_123')
expect(InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value).to eq('meter_123')
end
end
end
@@ -0,0 +1,217 @@
require 'rails_helper'
describe Enterprise::Billing::V2::SubscriptionProvisioningService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:subscription_id) { 'bpps_subscription_123' }
let(:pricing_plan_id) { 'bpp_business_plan_123' }
before do
account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
end
describe '#provision' do
let(:subscription_response) do
OpenStruct.new(
id: subscription_id,
pricing_plan: pricing_plan_id,
component_values: [
{ 'type' => 'license_fee', 'quantity' => 5 }
]
)
end
before do
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
end
it 'retrieves subscription details from Stripe' do
service.provision(subscription_id: subscription_id)
expect(StripeV2Client).to have_received(:request).with(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
hash_including(api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview')
)
end
it 'updates account custom attributes with subscription details' do
result = service.provision(subscription_id: subscription_id)
expect(result[:success]).to be(true)
expect(account.custom_attributes['stripe_billing_version']).to eq(2)
expect(account.custom_attributes['stripe_subscription_id']).to eq(subscription_id)
expect(account.custom_attributes['stripe_pricing_plan_id']).to eq(pricing_plan_id)
expect(account.custom_attributes['subscribed_quantity']).to eq(5)
expect(account.custom_attributes['subscription_status']).to eq('active')
expect(account.custom_attributes['plan_name']).to eq('Chatwoot Business')
end
it 'syncs monthly credits based on plan' do
credit_service = instance_double(Enterprise::Billing::V2::CreditManagementService)
allow(Enterprise::Billing::V2::CreditManagementService)
.to receive(:new)
.with(account: account)
.and_return(credit_service)
allow(credit_service).to receive(:sync_monthly_credits)
service.provision(subscription_id: subscription_id)
expect(credit_service).to have_received(:sync_monthly_credits).with(50_000)
end
it 'enables plan-specific features' do
service.provision(subscription_id: subscription_id)
expected_features = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
sla
custom_roles
]
enabled_feature_names = account.enabled_features.map(&:first)
expect(enabled_feature_names).to match_array(expected_features)
end
it 'returns success response with subscription details' do
result = service.provision(subscription_id: subscription_id)
expect(result[:success]).to be(true)
expect(result[:subscription_id]).to eq(subscription_id)
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
expect(result[:quantity]).to eq(5)
expect(result[:message]).to eq('Subscription provisioned successfully')
end
context 'when pricing plan has no license fee component' do
let(:subscription_response) do
OpenStruct.new(
id: subscription_id,
pricing_plan: pricing_plan_id,
component_values: []
)
end
before do
# Clear pending_subscription_quantity to test default behavior
account.update!(custom_attributes: account.custom_attributes.except('pending_subscription_quantity'))
end
it 'defaults quantity to 1' do
result = service.provision(subscription_id: subscription_id)
expect(result[:quantity]).to eq(1)
expect(account.custom_attributes['subscribed_quantity']).to eq(1)
end
end
context 'when Stripe API returns an error' do
before do
allow(StripeV2Client).to receive(:request).and_raise(Stripe::StripeError.new('API error'))
end
it 'returns error response' do
result = service.provision(subscription_id: subscription_id)
expect(result[:success]).to be(false)
expect(result[:message]).to include('Stripe error')
end
end
context 'when an unexpected error occurs' do
before do
allow(StripeV2Client).to receive(:request).and_raise(StandardError.new('Unexpected error'))
end
it 'returns error response' do
result = service.provision(subscription_id: subscription_id)
expect(result[:success]).to be(false)
expect(result[:message]).to include('Provisioning error')
end
end
context 'with Startup plan' do
let(:startup_plan_id) { 'bpp_startup_plan_123' }
let(:subscription_response) do
OpenStruct.new(
id: subscription_id,
pricing_plan: startup_plan_id,
component_values: [{ 'type' => 'license_fee', 'quantity' => 3 }]
)
end
before do
create(:installation_config, name: 'STRIPE_STARTUP_PLAN_ID', value: startup_plan_id)
end
it 'enables only Startup features' do
service.provision(subscription_id: subscription_id)
expected_features = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
]
enabled_feature_names = account.enabled_features.map(&:first)
expect(enabled_feature_names).to match_array(expected_features)
end
end
context 'with Enterprise plan' do
let(:enterprise_plan_id) { 'bpp_enterprise_plan_123' }
let(:subscription_response) do
OpenStruct.new(
id: subscription_id,
pricing_plan: enterprise_plan_id,
component_values: [{ 'type' => 'license_fee', 'quantity' => 10 }]
)
end
before do
create(:installation_config, name: 'STRIPE_ENTERPRISE_PLAN_ID', value: enterprise_plan_id)
end
it 'enables all features including Enterprise-specific ones' do
service.provision(subscription_id: subscription_id)
expected_features = %w[
inbound_emails
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
sla
custom_roles
audit_logs
disable_branding
saml
]
enabled_feature_names = account.enabled_features.map(&:first)
expect(enabled_feature_names).to match_array(expected_features)
end
end
end
end
@@ -0,0 +1,135 @@
require 'rails_helper'
require 'ostruct'
describe Enterprise::Billing::V2::TopupService do
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) }
let(:service) { described_class.new(account: account) }
let(:invoice) { Stripe::Invoice.construct_from(id: 'in_123', customer: 'cus_123', currency: 'usd') }
let(:invoice_item) do
Stripe::InvoiceItem.construct_from(
id: 'ii_123',
invoice: 'in_123',
amount: 5000,
currency: 'usd'
)
end
let(:finalized_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'open') }
let(:paid_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'paid') }
let(:credit_grant) do
{
'id' => 'credgr_123',
'customer' => 'cus_123',
'amount' => { 'type' => 'monetary', 'monetary' => { 'value' => 5000, 'currency' => 'usd' } }
}
end
before do
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).with(500).and_return(
credits: 500,
amount: 50.0,
currency: 'usd'
)
# Mock Stripe Invoice creation
allow(Stripe::Invoice).to receive(:create).and_return(invoice)
# Mock Stripe InvoiceItem creation
allow(Stripe::InvoiceItem).to receive(:create).and_return(invoice_item)
# Mock Stripe Invoice finalization
allow(Stripe::Invoice).to receive(:finalize_invoice).and_return(finalized_invoice)
# Mock Stripe Invoice payment
allow(Stripe::Invoice).to receive(:pay).and_return(paid_invoice)
# Mock Stripe Credit Grant creation (monetary amount)
allow(Stripe::Billing::CreditGrant).to receive(:create).and_return(credit_grant)
end
describe '#create_topup' do
context 'when successful' do
before do
service.create_topup(credits: 500)
end
it 'creates invoice with correct parameters' do
expect(Stripe::Invoice).to have_received(:create).with(
hash_including(
customer: 'cus_123',
currency: 'usd',
collection_method: 'charge_automatically'
),
hash_including(:api_key)
)
end
it 'creates invoice item with topup amount' do
expect(Stripe::InvoiceItem).to have_received(:create).with(
hash_including(
customer: 'cus_123',
amount: 5000,
currency: 'usd',
invoice: 'in_123',
description: 'Credit Topup: 500 credits'
),
hash_including(:api_key)
)
end
it 'finalizes invoice for payment' do
expect(Stripe::Invoice).to have_received(:finalize_invoice).with(
'in_123',
hash_including(auto_advance: false),
hash_including(:api_key)
)
end
it 'pays the invoice explicitly' do
expect(Stripe::Invoice).to have_received(:pay).with(
'in_123',
{},
hash_including(:api_key)
)
end
it 'creates credit grant with monetary amount' do
expect(Stripe::Billing::CreditGrant).to have_received(:create).with(
hash_including(
customer: 'cus_123',
name: 'Topup: 500 credits',
amount: hash_including(
type: 'monetary',
monetary: hash_including(currency: 'usd', value: 5000)
),
applicability_config: hash_including(scope: hash_including(price_type: 'metered')),
category: 'paid',
metadata: hash_including(
account_id: account.id.to_s,
source: 'topup',
credits: '500'
)
),
hash_including(:api_key)
)
end
it 'returns success response with all ids' do
result = service.create_topup(credits: 500)
expect(result[:success]).to be true
expect(result[:credits]).to eq(500)
expect(result[:invoice_id]).to eq('in_123')
expect(result[:credit_grant_id]).to eq('credgr_123')
end
end
it 'returns error when option missing' do
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).and_return(nil)
result = service.create_topup(credits: 999)
expect(result[:success]).to be false
expect(result[:message]).to eq('Unsupported topup amount')
end
end
end
@@ -7,64 +7,76 @@ describe Enterprise::Billing::V2::WebhookHandlerService do
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
before do
account.update!(custom_attributes: { 'stripe_billing_version' => 2 })
account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).with(account: account).and_return(credit_service)
end
def build_event(type:, object:)
data = double('Stripe::Event::Data', object: object)
double('Stripe::Event', type: type, data: data)
end
describe '#process' do
context 'when handling monthly credit grant' do
it 'syncs monthly credits from Stripe' do
context 'when handling subscription servicing activated' do
let(:subscription_response) do
OpenStruct.new(
id: 'bpps_subscription_123',
pricing_plan: 'bpp_business_plan_123',
component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }]
)
end
let(:event) do
double(
'Stripe::Event',
type: 'v2.billing.pricing_plan_subscription.servicing_activated',
related_object: OpenStruct.new(id: 'bpps_subscription_123')
)
end
let(:expected_features) do
%w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook
channel_email channel_instagram captain_integration advanced_search_indexing sla custom_roles]
end
before do
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: 'bpp_business_plan_123')
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
allow(credit_service).to receive(:sync_monthly_credits)
grant = OpenStruct.new(
amount: { 'custom_pricing_unit' => { 'value' => '2000' } },
expires_at: Time.current
)
event = build_event(type: 'billing.credit_grant.created', object: grant)
result = service.process(event)
expect(result[:success]).to be(true)
expect(credit_service).to have_received(:sync_monthly_credits).with(2000)
end
end
context 'when handling topup credit grant' do
it 'adds topup credits' do
allow(credit_service).to receive(:add_topup_credits)
grant = OpenStruct.new(
amount: { 'custom_pricing_unit' => { 'value' => '500' } },
expires_at: nil
)
event = build_event(type: 'billing.credit_grant.created', object: grant)
it 'returns success with subscription details' do
result = service.process(event)
expect(result[:success]).to be(true)
expect(credit_service).to have_received(:add_topup_credits).with(500)
expect(result[:success]).to be(true), "Expected success but got: #{result.inspect}"
expect(result[:subscription_id]).to eq('bpps_subscription_123')
expect(result[:pricing_plan_id]).to eq('bpp_business_plan_123')
expect(result[:quantity]).to eq(5)
end
end
context 'when handling credit grant update with expiration' do
it 'expires monthly credits when grant is expired' do
allow(credit_service).to receive(:expire_monthly_credits).and_return(100)
grant = OpenStruct.new(expired_at: Time.current)
event = build_event(type: 'billing.credit_grant.updated', object: grant)
it 'updates account custom attributes' do
service.process(event)
result = service.process(event)
expect(account.custom_attributes['stripe_subscription_id']).to eq('bpps_subscription_123')
expect(account.custom_attributes['stripe_pricing_plan_id']).to eq('bpp_business_plan_123')
expect(account.custom_attributes['subscribed_quantity']).to eq(5)
expect(account.custom_attributes['subscription_status']).to eq('active')
expect(account.custom_attributes['plan_name']).to eq('Chatwoot Business')
end
expect(result[:success]).to be(true)
expect(credit_service).to have_received(:expire_monthly_credits)
it 'syncs monthly credits' do
service.process(event)
expect(credit_service).to have_received(:sync_monthly_credits).with(50_000)
end
it 'provisions features for the plan' do
service.process(event)
enabled_feature_names = account.enabled_features.map(&:first)
expect(enabled_feature_names).to match_array(expected_features)
end
end
context 'when handling unknown event' do
it 'returns success' do
event = build_event(type: 'unknown.event', object: {})
related_object = OpenStruct.new(id: 'unknown_id')
event = double('Stripe::Event', type: 'unknown.event', related_object: related_object)
result = service.process(event)