Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe
This commit is contained in:
@@ -104,3 +104,7 @@ CLAUDE.local.md
|
||||
|
||||
# External documentation
|
||||
\[External\]*
|
||||
|
||||
|
||||
pricing-plans/
|
||||
scripts/
|
||||
@@ -159,7 +159,7 @@ gem 'working_hours'
|
||||
gem 'pg_search'
|
||||
|
||||
# Subscriptions, Billing
|
||||
gem 'stripe'
|
||||
gem 'stripe', '17.2.0.pre.beta.1'
|
||||
|
||||
## - helper gems --##
|
||||
## to populate db with sample data
|
||||
|
||||
+2
-2
@@ -902,7 +902,7 @@ GEM
|
||||
squasher (0.7.2)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (16.0.0)
|
||||
stripe (17.2.0.pre.beta.1)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.4.0)
|
||||
@@ -1110,7 +1110,7 @@ DEPENDENCIES
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stackprof
|
||||
stripe
|
||||
stripe (= 17.2.0.pre.beta.1)
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
|
||||
@@ -71,7 +71,6 @@ class DashboardController < ActionController::Base
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
STRIPE_BILLING_V2_ENABLED: GlobalConfigService.load('STRIPE_BILLING_V2_ENABLED', 'false'),
|
||||
GIT_SHA: GIT_HASH
|
||||
}
|
||||
end
|
||||
|
||||
@@ -9,6 +9,7 @@ if resource.custom_attributes.present?
|
||||
json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present?
|
||||
json.pending_stripe_pricing_plan_id resource.custom_attributes['pending_stripe_pricing_plan_id'] if resource.custom_attributes['pending_stripe_pricing_plan_id'].present?
|
||||
json.pending_subscription_quantity resource.custom_attributes['pending_subscription_quantity'] if resource.custom_attributes['pending_subscription_quantity'].present?
|
||||
json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present?
|
||||
json.next_billing_date resource.custom_attributes['next_billing_date'] if resource.custom_attributes['next_billing_date'].present?
|
||||
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
|
||||
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
|
||||
@@ -19,10 +20,6 @@ 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_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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Add these to your config/sidekiq_cron.yml or config/schedule.yml
|
||||
|
||||
v2_credit_sync:
|
||||
cron: "0 */6 * * *" # Every 6 hours
|
||||
cron: "0 * * * *" # Every hour
|
||||
class: "Enterprise::Billing::CreditSyncJob"
|
||||
queue: low
|
||||
description: "Sync V2 billing credits with Stripe"
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
# 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_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
|
||||
@@ -41,7 +41,7 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2
|
||||
)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, redirect_url: result[:redirect_url], checkout_session_id: result[:checkout_session_id] }
|
||||
render json: { success: true, redirect_url: result[:redirect_url], session_id: result[:session_id] }
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
class Enterprise::Billing::CreditSyncJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account = nil)
|
||||
if account
|
||||
sync_single_account(account)
|
||||
else
|
||||
sync_all_accounts
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_all_accounts
|
||||
Rails.logger.info '[CreditSyncJob] Starting credit sync for all accounts'
|
||||
|
||||
accounts_with_stripe = Account.where(
|
||||
"custom_attributes->>'stripe_customer_id' IS NOT NULL AND custom_attributes->>'stripe_billing_version' = '2'"
|
||||
)
|
||||
synced_count = 0
|
||||
failed_count = 0
|
||||
|
||||
accounts_with_stripe.find_each do |account|
|
||||
result = sync_account_credits(account)
|
||||
if result[:success]
|
||||
synced_count += 1 if result[:credits_reported].to_i.positive?
|
||||
else
|
||||
failed_count += 1
|
||||
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "[CreditSyncJob] Completed. Synced: #{synced_count}, Failed: #{failed_count}"
|
||||
{ synced: synced_count, failed: failed_count }
|
||||
end
|
||||
|
||||
def sync_single_account(account)
|
||||
Rails.logger.info "[CreditSyncJob] Syncing credits for account #{account.id}"
|
||||
result = sync_account_credits(account)
|
||||
|
||||
if result[:success]
|
||||
Rails.logger.info "[CreditSyncJob] Successfully synced account #{account.id}"
|
||||
else
|
||||
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def sync_account_credits(account)
|
||||
consumed_credits = account.custom_attributes&.[]('captain_responses_usage').to_i
|
||||
last_synced_credits = account.custom_attributes&.[]('stripe_last_synced_credits').to_i
|
||||
credits_to_report = consumed_credits - last_synced_credits
|
||||
|
||||
if credits_to_report.positive?
|
||||
handle_positive_credits(account, credits_to_report, consumed_credits)
|
||||
elsif credits_to_report.negative?
|
||||
handle_negative_credits(account, credits_to_report, consumed_credits)
|
||||
else
|
||||
{ success: true, message: 'Already in sync', credits_reported: 0 }
|
||||
end
|
||||
rescue StandardError => e
|
||||
handle_sync_error(account, e)
|
||||
end
|
||||
|
||||
def handle_positive_credits(account, credits_to_report, consumed_credits)
|
||||
reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account)
|
||||
result = reporter.report(credits_to_report)
|
||||
|
||||
return result unless result[:success]
|
||||
|
||||
update_last_synced_credits(account, consumed_credits)
|
||||
Rails.logger.info "[CreditSyncJob] Account #{account.id}: reported #{credits_to_report} credits (total: #{consumed_credits})"
|
||||
result.merge(credits_reported: credits_to_report)
|
||||
end
|
||||
|
||||
def handle_negative_credits(account, credits_to_report, consumed_credits)
|
||||
Rails.logger.warn "[CreditSyncJob] Account #{account.id} has negative difference: #{credits_to_report}"
|
||||
update_last_synced_credits(account, consumed_credits)
|
||||
{ success: true, message: 'Reset sync point due to negative difference', credits_reported: 0 }
|
||||
end
|
||||
|
||||
def handle_sync_error(account, error)
|
||||
Rails.logger.error "[CreditSyncJob] Error syncing account #{account.id}: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
{ success: false, message: error.message }
|
||||
end
|
||||
|
||||
def update_last_synced_credits(account, credits)
|
||||
account.with_lock do
|
||||
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
|
||||
current_attributes['stripe_last_synced_credits'] = credits
|
||||
account.update!(custom_attributes: current_attributes)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
module Enterprise::Billing::Concerns::BillingIntentWorkflow
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# Execute a billing intent with automatic reserve and commit
|
||||
def execute_billing_intent(intent_params)
|
||||
intent = create_billing_intent(intent_params)
|
||||
reserve_billing_intent(intent)
|
||||
yield(intent) if block_given?
|
||||
commit_billing_intent(intent)
|
||||
intent
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module Enterprise::Billing::Concerns::PlanDataHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def fetch_plan_version(plan_id)
|
||||
plan = retrieve_pricing_plan(plan_id)
|
||||
version = extract_attribute(plan, :latest_version)
|
||||
raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank?
|
||||
|
||||
version
|
||||
end
|
||||
|
||||
def fetch_plan_lookup_key(plan_id)
|
||||
lookup_key = Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id)
|
||||
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless lookup_key
|
||||
|
||||
lookup_key
|
||||
end
|
||||
|
||||
def plan_display_name(plan_id)
|
||||
Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan'
|
||||
end
|
||||
end
|
||||
+21
-32
@@ -1,5 +1,6 @@
|
||||
module Enterprise::Billing::Concerns::ProrationLineItemBuilder
|
||||
extend ActiveSupport::Concern
|
||||
include Enterprise::Billing::Concerns::PlanDataHelper
|
||||
|
||||
private
|
||||
|
||||
@@ -32,10 +33,6 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder
|
||||
line_items
|
||||
end
|
||||
|
||||
def plan_display_name(plan_id)
|
||||
Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan'
|
||||
end
|
||||
|
||||
def build_credit_line_item(context, proration_data, old_plan_name)
|
||||
{
|
||||
amount: -(proration_data[:credit_amount] * 100).to_i,
|
||||
@@ -60,26 +57,6 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder
|
||||
"Prorated charge for #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
|
||||
end
|
||||
|
||||
def credit_metadata(plan_name, quantity, days_remaining)
|
||||
{
|
||||
type: 'proration_credit',
|
||||
old_plan: plan_name,
|
||||
old_quantity: quantity,
|
||||
days_remaining: days_remaining,
|
||||
billing_version: 'v2'
|
||||
}
|
||||
end
|
||||
|
||||
def charge_metadata(plan_name, quantity, days_remaining)
|
||||
{
|
||||
type: 'proration_charge',
|
||||
new_plan: plan_name,
|
||||
new_quantity: quantity,
|
||||
days_remaining: days_remaining,
|
||||
billing_version: 'v2'
|
||||
}
|
||||
end
|
||||
|
||||
def build_seat_change_description(context)
|
||||
plan_name = plan_display_name(context[:target_plan_id])
|
||||
change_type = context[:target_quantity] > context[:old_quantity] ? 'increase' : 'decrease'
|
||||
@@ -89,15 +66,27 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder
|
||||
"seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})"
|
||||
end
|
||||
|
||||
def build_seat_change_metadata(context, proration_data)
|
||||
def base_metadata(type, days_remaining, **additional_fields)
|
||||
{
|
||||
type: 'seat_change',
|
||||
plan_name: plan_display_name(context[:target_plan_id]),
|
||||
old_quantity: context[:old_quantity],
|
||||
new_quantity: context[:target_quantity],
|
||||
quantity_change: context[:target_quantity] - context[:old_quantity],
|
||||
days_remaining: proration_data[:days_remaining],
|
||||
type: type,
|
||||
days_remaining: days_remaining,
|
||||
billing_version: 'v2'
|
||||
}
|
||||
}.merge(additional_fields)
|
||||
end
|
||||
|
||||
def credit_metadata(plan_name, quantity, days_remaining)
|
||||
base_metadata('proration_credit', days_remaining, old_plan: plan_name, old_quantity: quantity)
|
||||
end
|
||||
|
||||
def charge_metadata(plan_name, quantity, days_remaining)
|
||||
base_metadata('proration_charge', days_remaining, new_plan: plan_name, new_quantity: quantity)
|
||||
end
|
||||
|
||||
def build_seat_change_metadata(context, proration_data)
|
||||
base_metadata('seat_change', proration_data[:days_remaining],
|
||||
plan_name: plan_display_name(context[:target_plan_id]),
|
||||
old_quantity: context[:old_quantity],
|
||||
new_quantity: context[:target_quantity],
|
||||
quantity_change: context[:target_quantity] - context[:old_quantity])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,99 +3,57 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
private
|
||||
|
||||
# Generic Stripe V2 API request wrapper
|
||||
# Stripe client instance with API key
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(ENV.fetch('STRIPE_SECRET_KEY', nil))
|
||||
end
|
||||
|
||||
# Generic Stripe V2 API request wrapper (for methods not available in gem)
|
||||
def stripe_v2_request(method, path, params = {}, api_version: nil)
|
||||
StripeV2Client.request(method, path, params, stripe_api_options(api_version))
|
||||
end
|
||||
|
||||
# Pricing Plan Subscriptions
|
||||
def retrieve_pricing_plan_subscription(subscription_id)
|
||||
stripe_v2_request(:get, "/v2/billing/pricing_plan_subscriptions/#{subscription_id}")
|
||||
stripe_client.v2.billing.pricing_plan_subscriptions.retrieve(subscription_id)
|
||||
end
|
||||
|
||||
# Pricing Plans
|
||||
def retrieve_pricing_plan(pricing_plan_id)
|
||||
stripe_v2_request(:get, "/v2/billing/pricing_plans/#{pricing_plan_id}")
|
||||
end
|
||||
|
||||
def create_pricing_plan(params)
|
||||
stripe_v2_request(:post, '/v2/billing/pricing_plans', params)
|
||||
stripe_client.v2.billing.pricing_plans.retrieve(pricing_plan_id)
|
||||
end
|
||||
|
||||
# gem not available - using custom HTTP client
|
||||
def update_pricing_plan(plan_id, params)
|
||||
stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}", params)
|
||||
end
|
||||
|
||||
# Billing Cadences
|
||||
def retrieve_billing_cadence(cadence_id)
|
||||
stripe_v2_request(:get, "/v2/billing/cadences/#{cadence_id}")
|
||||
stripe_client.v2.billing.cadences.retrieve(cadence_id)
|
||||
end
|
||||
|
||||
# Billing Intents
|
||||
# gem not available - using custom HTTP client
|
||||
def create_billing_intent(params)
|
||||
stripe_v2_request(:post, '/v2/billing/intents', params)
|
||||
end
|
||||
|
||||
def reserve_billing_intent(billing_intent)
|
||||
stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/reserve")
|
||||
stripe_client.v2.billing.intents.reserve(billing_intent.id)
|
||||
end
|
||||
|
||||
def commit_billing_intent(billing_intent)
|
||||
stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/commit")
|
||||
end
|
||||
|
||||
# Custom Pricing Units
|
||||
def create_custom_pricing_unit(params)
|
||||
stripe_v2_request(:post, '/v2/billing/custom_pricing_units', params)
|
||||
end
|
||||
|
||||
# Licensed Items
|
||||
def create_licensed_item(params)
|
||||
stripe_v2_request(:post, '/v2/billing/licensed_items', params)
|
||||
end
|
||||
|
||||
# License Fees
|
||||
def create_license_fee(params)
|
||||
stripe_v2_request(:post, '/v2/billing/license_fees', params)
|
||||
end
|
||||
|
||||
# Service Actions
|
||||
def create_service_action(params)
|
||||
stripe_v2_request(:post, '/v2/billing/service_actions', params)
|
||||
end
|
||||
|
||||
# Rate Cards
|
||||
def create_rate_card(params)
|
||||
stripe_v2_request(:post, '/v2/billing/rate_cards', params)
|
||||
end
|
||||
|
||||
def add_rate_to_card(card_id, params)
|
||||
stripe_v2_request(:post, "/v2/billing/rate_cards/#{card_id}/rates", params)
|
||||
end
|
||||
|
||||
# Metered Items
|
||||
def create_metered_item(params)
|
||||
stripe_v2_request(:post, '/v2/billing/metered_items', params)
|
||||
end
|
||||
|
||||
# Meters (V1 API but used with V2)
|
||||
def create_meter(params)
|
||||
stripe_v2_request(:post, '/v1/billing/meters', params)
|
||||
end
|
||||
|
||||
# Pricing Plan Components
|
||||
def add_pricing_plan_component(plan_id, params)
|
||||
stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}/components", params)
|
||||
stripe_client.v2.billing.intents.commit(billing_intent.id)
|
||||
end
|
||||
|
||||
# Checkout Sessions (V1 API but used with V2 plans)
|
||||
def create_checkout_session(params, api_version: nil)
|
||||
stripe_v2_request(:post, '/v1/checkout/sessions', params, api_version: api_version)
|
||||
def create_checkout_session(params)
|
||||
Stripe::Checkout::Session.create(params, { stripe_version: checkout_stripe_version })
|
||||
end
|
||||
|
||||
# Credit Grants (V1 API but used with V2)
|
||||
def retrieve_credit_grant(grant_id)
|
||||
stripe_v2_request(:get, "/v1/billing/credit_grants/#{grant_id}")
|
||||
Stripe::Billing::CreditGrant.retrieve(grant_id)
|
||||
end
|
||||
|
||||
# API Options with support for custom versions
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
module Enterprise::Billing::Concerns::SubscriptionDataManager
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def fetch_subscription_metadata
|
||||
subscription_id = fetch_subscription_id
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
cadence_id = extract_cadence_id(subscription)
|
||||
cadence = retrieve_billing_cadence(cadence_id)
|
||||
next_billing_date = extract_attribute(cadence, :next_billing_date)
|
||||
|
||||
store_next_billing_date(next_billing_date)
|
||||
|
||||
{
|
||||
subscription_id: subscription_id,
|
||||
subscription: subscription,
|
||||
cadence_id: cadence_id,
|
||||
cadence: cadence,
|
||||
next_billing_date: next_billing_date
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_subscription_id
|
||||
subscription_id = custom_attribute('stripe_subscription_id')
|
||||
raise StandardError, 'No pricing plan subscription ID found' if subscription_id.blank?
|
||||
|
||||
subscription_id
|
||||
end
|
||||
|
||||
def extract_cadence_id(subscription)
|
||||
cadence_id = extract_attribute(subscription, :billing_cadence)
|
||||
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
|
||||
|
||||
cadence_id
|
||||
end
|
||||
|
||||
def store_next_billing_date(next_billing_date)
|
||||
update_custom_attributes({ 'next_billing_date' => next_billing_date })
|
||||
end
|
||||
end
|
||||
@@ -8,26 +8,23 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
def perform
|
||||
return if existing_subscription?
|
||||
|
||||
customer_id = prepare_customer_id
|
||||
raise_config_error unless v2_configs_present?
|
||||
|
||||
if billing_v2_enabled? && v2_configs_present?
|
||||
update_account_for_v2_billing(customer_id)
|
||||
enable_plan_specific_features(default_plan['name'])
|
||||
elsif default_plan.present?
|
||||
subscription = create_subscription(customer_id)
|
||||
update_account_with_subscription(customer_id, subscription)
|
||||
else
|
||||
# Minimal setup when configs are missing
|
||||
account.update!(custom_attributes: { stripe_customer_id: customer_id })
|
||||
end
|
||||
customer_id = prepare_customer_id
|
||||
update_account_for_v2_billing(customer_id)
|
||||
enable_plan_specific_features(default_plan['name'])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_customer_id
|
||||
customer_id = account.custom_attributes['stripe_customer_id']
|
||||
test_clock = Stripe::TestHelpers::TestClock.create({
|
||||
frozen_time: Time.now.to_i,
|
||||
name: 'Test Clock for Account'
|
||||
})
|
||||
if customer_id.blank?
|
||||
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
|
||||
customer = Stripe::Customer.create({ name: account.name, email: billing_email, test_clock: test_clock.id })
|
||||
customer_id = customer.id
|
||||
end
|
||||
customer_id
|
||||
@@ -46,20 +43,15 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
@default_plan ||= installation_config&.value&.first
|
||||
end
|
||||
|
||||
def price_id
|
||||
price_ids = default_plan['price_ids']
|
||||
price_ids.first
|
||||
end
|
||||
|
||||
def billing_v2_enabled?
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
|
||||
def v2_configs_present?
|
||||
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').present? &&
|
||||
default_plan.present?
|
||||
end
|
||||
|
||||
def raise_config_error
|
||||
raise StandardError, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID and CHATWOOT_CLOUD_PLANS.'
|
||||
end
|
||||
|
||||
def existing_subscription?
|
||||
stripe_customer_id = account.custom_attributes['stripe_customer_id']
|
||||
return false if stripe_customer_id.blank?
|
||||
@@ -74,17 +66,13 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
subscriptions.data.present?
|
||||
end
|
||||
|
||||
def create_subscription(customer_id)
|
||||
Stripe::Subscription.create(
|
||||
customer: customer_id,
|
||||
items: [{ price: price_id, quantity: default_quantity }]
|
||||
)
|
||||
end
|
||||
|
||||
def update_account_for_v2_billing(customer_id)
|
||||
hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID')
|
||||
|
||||
attributes = { stripe_customer_id: customer_id }
|
||||
attributes = {
|
||||
stripe_customer_id: customer_id,
|
||||
stripe_billing_version: 'v2'
|
||||
}
|
||||
|
||||
if hacker_plan_config&.value.present?
|
||||
attributes.merge!(
|
||||
@@ -96,16 +84,4 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
|
||||
account.update!(custom_attributes: attributes)
|
||||
end
|
||||
|
||||
def update_account_with_subscription(customer_id, subscription)
|
||||
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']
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,10 +13,6 @@ class Enterprise::Billing::V2::BaseService
|
||||
)
|
||||
end
|
||||
|
||||
def v2_enabled?
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
|
||||
def response_monthly_credits
|
||||
account.limits&.[]('captain_responses_monthly').to_i
|
||||
end
|
||||
@@ -75,9 +71,20 @@ class Enterprise::Billing::V2::BaseService
|
||||
account.with_lock(&)
|
||||
end
|
||||
|
||||
def with_stripe_error_handling
|
||||
yield
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: e.message }
|
||||
# Convenient accessors for common attributes
|
||||
def stripe_customer_id
|
||||
custom_attribute('stripe_customer_id')
|
||||
end
|
||||
|
||||
def stripe_subscription_id
|
||||
custom_attribute('stripe_subscription_id')
|
||||
end
|
||||
|
||||
def pricing_plan_id
|
||||
custom_attribute('stripe_pricing_plan_id')
|
||||
end
|
||||
|
||||
def subscribed_quantity
|
||||
custom_attribute('subscribed_quantity').to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
include Enterprise::Billing::Concerns::BillingIntentWorkflow
|
||||
include Enterprise::Billing::Concerns::SubscriptionDataManager
|
||||
|
||||
# Cancel subscription using Stripe's V2 Billing Intent API
|
||||
# Creates a deactivate billing intent for the pricing plan subscription
|
||||
@@ -10,12 +12,11 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
|
||||
#
|
||||
def cancel_subscription
|
||||
with_locked_account do
|
||||
billing_intent = create_deactivate_intent
|
||||
reserve_billing_intent(billing_intent)
|
||||
commit_billing_intent(billing_intent)
|
||||
update_account_status(billing_intent)
|
||||
success_response = build_success_response(billing_intent)
|
||||
success_response
|
||||
metadata = fetch_subscription_metadata
|
||||
intent_params = build_deactivate_params(metadata[:subscription_id], metadata[:cadence_id])
|
||||
execute_billing_intent(intent_params)
|
||||
update_account_status(metadata[:next_billing_date])
|
||||
build_success_response
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe error: #{e.message}" }
|
||||
@@ -25,35 +26,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
|
||||
|
||||
private
|
||||
|
||||
def create_deactivate_intent
|
||||
pricing_plan_subscription_id = fetch_subscription_id
|
||||
billing_cadence_id = fetch_billing_cadence_id(pricing_plan_subscription_id)
|
||||
store_next_billing_date(billing_cadence_id)
|
||||
|
||||
create_billing_intent(
|
||||
build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id)
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_subscription_id
|
||||
custom_attribute('stripe_subscription_id').tap do |id|
|
||||
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_billing_cadence_id(subscription_id)
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
extract_attribute(subscription, :billing_cadence).tap do |cadence_id|
|
||||
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
|
||||
end
|
||||
end
|
||||
|
||||
def store_next_billing_date(cadence_id)
|
||||
cadence = retrieve_billing_cadence(cadence_id)
|
||||
Rails.logger.info("Cadence: #{cadence}")
|
||||
@next_billing_date = extract_attribute(cadence, :next_billing_date)
|
||||
end
|
||||
|
||||
def build_deactivate_params(subscription_id, cadence_id)
|
||||
{
|
||||
cadence: cadence_id,
|
||||
@@ -68,17 +40,17 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
|
||||
}
|
||||
end
|
||||
|
||||
def update_account_status(_billing_intent)
|
||||
def update_account_status(next_billing_date)
|
||||
# Mark subscription as cancelling (will be cancelled at period end)
|
||||
# Store next_billing_date so the UI can show when the subscription ends
|
||||
update_custom_attributes({
|
||||
'subscription_status' => 'cancel_at_period_end',
|
||||
'subscription_cancelled_at' => Time.current.iso8601,
|
||||
'subscription_ends_at' => @next_billing_date
|
||||
'subscription_ends_at' => next_billing_date
|
||||
})
|
||||
end
|
||||
|
||||
def build_success_response(_billing_intent)
|
||||
def build_success_response
|
||||
{
|
||||
success: true,
|
||||
cancel_at_period_end: true,
|
||||
|
||||
@@ -3,15 +3,10 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
|
||||
include Enterprise::Billing::Concerns::ProrationLineItemBuilder
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
include Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||
include Enterprise::Billing::Concerns::BillingIntentWorkflow
|
||||
include Enterprise::Billing::Concerns::SubscriptionDataManager
|
||||
include Enterprise::Billing::Concerns::PlanDataHelper
|
||||
|
||||
# Change customer's pricing plan and/or seat quantity using invoice line items for proration
|
||||
# Instantly applies the change and creates pending invoice line items
|
||||
# for prorated charges that will be added to the next invoice
|
||||
#
|
||||
# @param new_pricing_plan_id [String, nil] The new Stripe pricing plan ID (nil to keep current plan)
|
||||
# @param quantity [Integer] The seat quantity for the plan
|
||||
# @return [Hash] { success:, message:, proration:, line_items: }
|
||||
#
|
||||
def change_plan(new_pricing_plan_id: nil, quantity: nil)
|
||||
validation_error = validate_parameters(new_pricing_plan_id, quantity)
|
||||
return validation_error if validation_error
|
||||
@@ -19,8 +14,6 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
|
||||
with_locked_account do
|
||||
perform_subscription_change(new_pricing_plan_id, quantity)
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe error: #{e.message}" }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -45,8 +38,8 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
|
||||
end
|
||||
|
||||
def build_change_context(new_pricing_plan_id, quantity)
|
||||
old_plan_id = custom_attribute('stripe_pricing_plan_id')
|
||||
old_quantity = custom_attribute('subscribed_quantity').to_i
|
||||
old_plan_id = pricing_plan_id
|
||||
old_quantity = subscribed_quantity
|
||||
target_plan_id = new_pricing_plan_id || old_plan_id
|
||||
target_quantity = quantity || old_quantity
|
||||
|
||||
@@ -65,15 +58,14 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
|
||||
end
|
||||
|
||||
def no_change_response(context)
|
||||
plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan'
|
||||
plan_name = plan_display_name(context[:target_plan_id])
|
||||
{ success: false, message: "Subscription already has plan #{plan_name} with #{context[:target_quantity]} seat(s)" }
|
||||
end
|
||||
|
||||
def execute_change(context)
|
||||
# Create billing intent to update the Stripe subscription
|
||||
billing_intent = create_change_plan_intent(context[:target_plan_id], context[:target_quantity])
|
||||
reserve_billing_intent(billing_intent)
|
||||
commit_billing_intent(billing_intent)
|
||||
# Create and execute billing intent to update the Stripe subscription
|
||||
intent_params = build_change_plan_params(context[:target_plan_id], context[:target_quantity])
|
||||
execute_billing_intent(intent_params)
|
||||
|
||||
next_billing_date = custom_attribute('next_billing_date')
|
||||
proration_data = calculate_proration(
|
||||
@@ -84,76 +76,39 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
|
||||
next_billing_date: next_billing_date
|
||||
)
|
||||
line_items = build_proration_line_items(context, proration_data)
|
||||
invoice_result = create_and_charge_invoice(line_items)
|
||||
create_and_charge_invoice(line_items)
|
||||
|
||||
update_account_plan(context[:target_plan_id], context[:target_quantity], next_billing_date)
|
||||
provision_new_plan(context[:target_plan_id]) if context[:plan_changed]
|
||||
|
||||
success_response(context, proration_data, line_items, invoice_result)
|
||||
{ success: true, message: 'Plan change successful' }
|
||||
end
|
||||
|
||||
def fetch_subscription_id
|
||||
custom_attribute('stripe_subscription_id').tap do |id|
|
||||
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_cadence_from_subscription(subscription_id)
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
extract_attribute(subscription, :billing_cadence).tap do |cadence_id|
|
||||
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
|
||||
end
|
||||
end
|
||||
|
||||
def create_change_plan_intent(new_pricing_plan_id, quantity)
|
||||
subscription_id = fetch_subscription_id
|
||||
cadence_id = fetch_cadence_from_subscription(subscription_id)
|
||||
store_next_billing_date(cadence_id)
|
||||
plan_version = fetch_new_plan_version(new_pricing_plan_id)
|
||||
def build_change_plan_params(new_pricing_plan_id, quantity)
|
||||
metadata = fetch_subscription_metadata
|
||||
plan_version = fetch_plan_version(new_pricing_plan_id)
|
||||
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
|
||||
component_config = { lookup_key: lookup_key, quantity: quantity }
|
||||
|
||||
create_billing_intent(
|
||||
build_change_plan_params(subscription_id, cadence_id, new_pricing_plan_id, plan_version, component_config)
|
||||
)
|
||||
end
|
||||
|
||||
def build_change_plan_params(subscription_id, cadence_id, plan_id, plan_version, component_config)
|
||||
{
|
||||
cadence: cadence_id,
|
||||
cadence: metadata[:cadence_id],
|
||||
currency: 'usd',
|
||||
actions: [{
|
||||
type: 'modify',
|
||||
modify: {
|
||||
type: 'pricing_plan_subscription_details',
|
||||
pricing_plan_subscription_details: {
|
||||
pricing_plan_subscription: subscription_id,
|
||||
new_pricing_plan: plan_id,
|
||||
new_pricing_plan_version: plan_version,
|
||||
component_configurations: [component_config]
|
||||
}
|
||||
}
|
||||
}]
|
||||
actions: [build_modify_action(metadata[:subscription_id], new_pricing_plan_id, plan_version, lookup_key, quantity)]
|
||||
}
|
||||
end
|
||||
|
||||
def store_next_billing_date(cadence_id)
|
||||
cadence = retrieve_billing_cadence(cadence_id)
|
||||
@next_billing_date = extract_attribute(cadence, :next_billing_date)
|
||||
update_custom_attributes({ 'next_billing_date' => @next_billing_date })
|
||||
end
|
||||
|
||||
def fetch_new_plan_version(plan_id)
|
||||
plan = retrieve_pricing_plan(plan_id)
|
||||
extract_attribute(plan, :latest_version).tap do |version|
|
||||
raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank?
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_plan_lookup_key(plan_id)
|
||||
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id).tap do |key|
|
||||
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless key
|
||||
end
|
||||
def build_modify_action(subscription_id, plan_id, plan_version, lookup_key, quantity)
|
||||
{
|
||||
type: 'modify',
|
||||
modify: {
|
||||
type: 'pricing_plan_subscription_details',
|
||||
pricing_plan_subscription_details: {
|
||||
pricing_plan_subscription: subscription_id,
|
||||
new_pricing_plan: plan_id,
|
||||
new_pricing_plan_version: plan_version,
|
||||
component_configurations: [{ lookup_key: lookup_key, quantity: quantity }]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:)
|
||||
@@ -184,34 +139,4 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def success_response(context, proration_data, line_items, invoice_result)
|
||||
{
|
||||
success: true,
|
||||
pricing_plan_id: context[:target_plan_id],
|
||||
quantity: context[:target_quantity],
|
||||
old_pricing_plan_id: context[:old_plan_id],
|
||||
old_quantity: context[:old_quantity],
|
||||
plan_changed: context[:plan_changed],
|
||||
seats_changed: context[:seats_changed],
|
||||
proration: proration_data,
|
||||
line_items: line_items,
|
||||
invoice: invoice_result,
|
||||
total_proration_amount: proration_data[:net_amount],
|
||||
message: build_change_message(context)
|
||||
}
|
||||
end
|
||||
|
||||
def build_change_message(context)
|
||||
plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan'
|
||||
base_message = if context[:plan_changed] && context[:seats_changed]
|
||||
"Plan changed to #{plan_name} and seats updated to #{context[:target_quantity]}"
|
||||
elsif context[:plan_changed]
|
||||
"Plan changed to #{plan_name}"
|
||||
else
|
||||
"Seats updated from #{context[:old_quantity]} to #{context[:target_quantity]}"
|
||||
end
|
||||
|
||||
"#{base_message} - billing intent committed and invoice line items created"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
# 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
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
# 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
|
||||
@@ -26,16 +12,14 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
|
||||
|
||||
validate_params
|
||||
store_pending_subscription_quantity
|
||||
create_checkout_session
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe API error: #{e.message}", error: e }
|
||||
session = create_checkout_session
|
||||
{ success: true, redirect_url: session.url }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_params
|
||||
customer_id = custom_attribute('stripe_customer_id')
|
||||
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if customer_id.blank?
|
||||
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank?
|
||||
end
|
||||
|
||||
def store_pending_subscription_quantity
|
||||
@@ -47,20 +31,9 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
|
||||
})
|
||||
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 = super(checkout_session_params(customer_id), api_version: checkout_stripe_version)
|
||||
build_success_response(session)
|
||||
end
|
||||
|
||||
def checkout_session_params(customer_id)
|
||||
def checkout_session_params
|
||||
{
|
||||
customer: customer_id,
|
||||
customer: stripe_customer_id,
|
||||
checkout_items: build_checkout_items,
|
||||
automatic_tax: {
|
||||
enabled: true
|
||||
@@ -109,15 +82,4 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
|
||||
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
|
||||
end
|
||||
|
||||
@@ -21,12 +21,10 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
end
|
||||
|
||||
def fetch_credit_grants
|
||||
customer_id = stripe_customer_id
|
||||
return [] if customer_id.blank?
|
||||
return [] if stripe_customer_id.blank?
|
||||
|
||||
response = Stripe::Billing::CreditGrant.list(
|
||||
{ customer: customer_id, limit: 100 },
|
||||
stripe_api_options
|
||||
{ customer: stripe_customer_id, limit: 100 }
|
||||
)
|
||||
|
||||
grants = response.data.map do |grant|
|
||||
@@ -83,12 +81,4 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
|
||||
Time.zone.at(timestamp)
|
||||
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
|
||||
|
||||
@@ -11,10 +11,9 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
# Validate that customer has a default payment method
|
||||
# @return [Hash, nil] Returns error hash if validation fails, nil if success
|
||||
def validate_payment_method
|
||||
customer_id = custom_attribute('stripe_customer_id')
|
||||
return { success: false, message: 'No Stripe customer ID found' } if customer_id.blank?
|
||||
return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank?
|
||||
|
||||
customer = Stripe::Customer.retrieve(customer_id, stripe_api_options)
|
||||
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||
|
||||
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
|
||||
return {
|
||||
@@ -37,9 +36,8 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
# @param metadata [Hash] Invoice metadata
|
||||
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
|
||||
def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {})
|
||||
customer_id = custom_attribute('stripe_customer_id')
|
||||
invoice = create_invoice(customer_id, currency, description, metadata)
|
||||
add_line_items_to_invoice(invoice.id, customer_id, line_items, currency)
|
||||
invoice = create_invoice(stripe_customer_id, currency, description, metadata)
|
||||
add_line_items_to_invoice(invoice.id, stripe_customer_id, line_items, currency)
|
||||
finalize_and_pay_invoice(invoice.id)
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error("Error creating invoice: #{e.message}")
|
||||
@@ -56,7 +54,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
auto_advance: false,
|
||||
description: description,
|
||||
metadata: metadata.stringify_keys
|
||||
}, stripe_api_options)
|
||||
})
|
||||
end
|
||||
|
||||
def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency)
|
||||
@@ -68,7 +66,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
invoice: invoice_id,
|
||||
description: item[:description],
|
||||
metadata: (item[:metadata] || {}).stringify_keys
|
||||
}, stripe_api_options)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,15 +77,14 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
# Finalize the invoice
|
||||
finalized_invoice = Stripe::Invoice.finalize_invoice(
|
||||
invoice_id,
|
||||
{ auto_advance: false },
|
||||
stripe_api_options
|
||||
{ auto_advance: false }
|
||||
)
|
||||
|
||||
# Pay the invoice immediately if not already paid
|
||||
if finalized_invoice.status == 'paid'
|
||||
build_invoice_response(finalized_invoice)
|
||||
else
|
||||
paid_invoice = Stripe::Invoice.pay(invoice_id, {}, stripe_api_options)
|
||||
paid_invoice = Stripe::Invoice.pay(invoice_id, {})
|
||||
build_invoice_response(paid_invoice)
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
@@ -104,8 +101,4 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
status: invoice.status
|
||||
}
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -93,11 +93,10 @@ module Enterprise::Billing::V2::PlanCatalog
|
||||
}
|
||||
end
|
||||
|
||||
def rate_card_component(definition)
|
||||
def rate_card_component(_definition)
|
||||
{
|
||||
type: 'rate_card',
|
||||
name: 'Overage Rate',
|
||||
overage_rate: definition[:overage_rate],
|
||||
rate_unit: CREDIT_UNIT,
|
||||
meter_id: nil
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
def add_license_fee_component(plan, config)
|
||||
licensed_item = create_licensed_item(
|
||||
display_name: config[:licensed_item_display_name],
|
||||
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,
|
||||
lookup_key: config[:lookup_key]
|
||||
)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'license_fee',
|
||||
data: { id: license_fee.id, version: license_fee.latest_version },
|
||||
lookup_key: config[:lookup_key]
|
||||
)
|
||||
end
|
||||
|
||||
def add_service_action_component(plan, config, cpu)
|
||||
action = create_service_action(
|
||||
lookup_key: config[:service_action_lookup_key],
|
||||
credit_amount: config[:monthly_credit_amount],
|
||||
cpu_id: cpu.id
|
||||
)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'service_action',
|
||||
data: { id: action.id },
|
||||
lookup_key: config[:service_action_lookup_key]
|
||||
)
|
||||
|
||||
action
|
||||
end
|
||||
|
||||
def add_rate_card_component(plan, config, meter, cpu)
|
||||
card = create_rate_card(display_name: config[:rate_card_display_name])
|
||||
|
||||
item = create_metered_item(
|
||||
display_name: config[:metered_item_display_name],
|
||||
lookup_key: config[:metered_item_lookup_key],
|
||||
meter_id: meter.id
|
||||
)
|
||||
|
||||
add_rate(card_id: card.id, item_id: item.id, cpu_id: cpu.id, value: config[:rate_value] || 1)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'rate_card',
|
||||
data: { id: card.id, version: card.latest_version },
|
||||
lookup_key: config[:metered_item_lookup_key]
|
||||
)
|
||||
|
||||
card
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_licensed_item(display_name:, lookup_key:, unit_label:)
|
||||
super({ display_name: display_name, lookup_key: lookup_key, unit_label: unit_label })
|
||||
end
|
||||
|
||||
def create_license_fee(display_name:, unit_amount:, licensed_item_id:, lookup_key:)
|
||||
super({
|
||||
display_name: display_name,
|
||||
currency: 'usd',
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
tax_behavior: 'exclusive',
|
||||
unit_amount: unit_amount.to_s,
|
||||
licensed_item: licensed_item_id,
|
||||
lookup_key: lookup_key
|
||||
})
|
||||
end
|
||||
|
||||
def create_service_action(lookup_key:, credit_amount:, cpu_id:)
|
||||
super(service_action_params(lookup_key, credit_amount, cpu_id))
|
||||
end
|
||||
|
||||
def service_action_params(lookup_key, credit_amount, cpu_id)
|
||||
{
|
||||
lookup_key: lookup_key,
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
type: 'credit_grant',
|
||||
credit_grant: credit_grant_config(credit_amount, cpu_id)
|
||||
}
|
||||
end
|
||||
|
||||
def credit_grant_config(credit_amount, cpu_id)
|
||||
{
|
||||
name: 'Monthly Credits',
|
||||
amount: {
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: { id: cpu_id, value: credit_amount.to_s }
|
||||
},
|
||||
expiry_config: { type: 'end_of_service_period' },
|
||||
applicability_config: { scope: { price_type: 'metered' } }
|
||||
}
|
||||
end
|
||||
|
||||
def create_rate_card(display_name:)
|
||||
super({
|
||||
display_name: display_name,
|
||||
currency: 'usd',
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
tax_behavior: 'exclusive'
|
||||
})
|
||||
end
|
||||
|
||||
def create_metered_item(display_name:, lookup_key:, meter_id:)
|
||||
super({ display_name: display_name, lookup_key: lookup_key, meter: meter_id })
|
||||
end
|
||||
|
||||
def add_rate(card_id:, item_id:, cpu_id:, value:)
|
||||
add_rate_to_card(card_id, {
|
||||
metered_item: item_id,
|
||||
custom_pricing_unit_amount: { id: cpu_id, value: value.to_s }
|
||||
})
|
||||
end
|
||||
|
||||
def add_component(plan_id:, type:, data:, lookup_key:)
|
||||
params = case type
|
||||
when 'license_fee'
|
||||
{ type: 'license_fee', license_fee: data, lookup_key: lookup_key }
|
||||
when 'service_action'
|
||||
{ type: 'service_action', service_action: data, lookup_key: lookup_key }
|
||||
when 'rate_card'
|
||||
{ type: 'rate_card', rate_card: data, lookup_key: lookup_key }
|
||||
end
|
||||
|
||||
add_pricing_plan_component(plan_id, params)
|
||||
end
|
||||
end
|
||||
@@ -1,104 +0,0 @@
|
||||
class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
def create_custom_pricing_unit(display_name:, lookup_key:)
|
||||
super({ display_name: display_name, lookup_key: lookup_key })
|
||||
end
|
||||
|
||||
def create_meter(display_name:, event_name:)
|
||||
params = {
|
||||
'display_name' => display_name,
|
||||
'event_name' => event_name,
|
||||
'default_aggregation[formula]' => 'sum',
|
||||
'customer_mapping[type]' => 'by_id',
|
||||
'customer_mapping[event_payload_key]' => 'stripe_customer_id',
|
||||
'value_settings[event_payload_key]' => 'value'
|
||||
}
|
||||
super(params)
|
||||
end
|
||||
|
||||
def create_pricing_plan(display_name:, lookup_key:, currency: 'usd', tax_behavior: 'exclusive')
|
||||
super({ display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key })
|
||||
end
|
||||
|
||||
def create_complete_pricing_plan(config)
|
||||
# Use shared CPU and meter if available, otherwise create new ones
|
||||
cpu = get_or_create_cpu(config)
|
||||
meter = get_or_create_meter(config)
|
||||
|
||||
plan = create_pricing_plan(display_name: config[:plan_display_name], lookup_key: config[:plan_lookup_key])
|
||||
|
||||
builder = component_builder
|
||||
builder.add_license_fee_component(plan, config) if config[:license_fee_amount].to_i.positive?
|
||||
builder.add_service_action_component(plan, config, cpu) if config[:monthly_credit_amount].to_i.positive?
|
||||
builder.add_rate_card_component(plan, config, meter, cpu)
|
||||
|
||||
# Make the latest version live
|
||||
make_plan_version_live(plan.id)
|
||||
|
||||
build_plan_result(plan, cpu, meter)
|
||||
end
|
||||
|
||||
def get_or_create_cpu(config)
|
||||
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
|
||||
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
|
||||
|
||||
def get_or_create_meter(config)
|
||||
# Check for shared meter first
|
||||
shared_meter_id = InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value ||
|
||||
ENV.fetch('STRIPE_METER_ID', nil)
|
||||
|
||||
if shared_meter_id
|
||||
# Return existing meter as OpenStruct to match create response
|
||||
OpenStruct.new(id: shared_meter_id)
|
||||
else
|
||||
# Create new meter if not using shared
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def component_builder
|
||||
@component_builder ||= Enterprise::Billing::V2::PricingPlanComponentBuilder.new(account: account)
|
||||
end
|
||||
|
||||
def make_plan_version_live(plan_id)
|
||||
update_pricing_plan(plan_id, { live_version: 'latest' })
|
||||
end
|
||||
|
||||
def build_plan_result(plan, cpu, meter)
|
||||
{
|
||||
success: true,
|
||||
pricing_plan: plan,
|
||||
custom_pricing_unit: cpu,
|
||||
meter: meter
|
||||
}
|
||||
end
|
||||
end
|
||||
+33
-45
@@ -4,54 +4,47 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
def provision(subscription_id:)
|
||||
process_subscription(subscription_id)
|
||||
end
|
||||
|
||||
def refresh
|
||||
return if stripe_subscription_id.blank?
|
||||
|
||||
process_subscription(stripe_subscription_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_subscription(subscription_id)
|
||||
# Retrieve pricing plan subscription details from Stripe V2 API
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
|
||||
# Check if subscription is canceled
|
||||
if servicing_status(subscription) == 'canceled'
|
||||
cancel_subscription
|
||||
reset_captain_usage
|
||||
return { pricing_plan_id: nil, quantity: nil }
|
||||
end
|
||||
|
||||
# Extract details from the subscription
|
||||
pricing_plan_id = extract_pricing_plan_id(subscription)
|
||||
quantity = extract_subscription_quantity(subscription)
|
||||
billing_cadence = extract_billing_cadence(subscription)
|
||||
|
||||
# Update account with subscription details
|
||||
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
|
||||
|
||||
# Provision the subscription: sync credits and enable features
|
||||
provision_new_plan(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}" }
|
||||
end
|
||||
|
||||
def refresh
|
||||
subscription_id = account.custom_attributes['stripe_subscription_id']
|
||||
return if subscription_id.blank?
|
||||
|
||||
subscription_plan = retrieve_pricing_plan_subscription(subscription_id)
|
||||
servicing_status = servicing_status(subscription_plan)
|
||||
pricing_plan_id = extract_pricing_plan_id(subscription_plan)
|
||||
if servicing_status == 'canceled'
|
||||
cancel_subscription
|
||||
else
|
||||
quantity = account.custom_attributes['subscribed_quantity']
|
||||
billing_cadence = extract_billing_cadence(subscription_plan)
|
||||
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
|
||||
end
|
||||
# Reset usage for the new billing cycle
|
||||
reset_captain_usage
|
||||
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'
|
||||
}
|
||||
{ pricing_plan_id: pricing_plan_id, quantity: quantity }
|
||||
end
|
||||
|
||||
def servicing_status(subscription_plan)
|
||||
subscription_plan.respond_to?(:servicing_status) ? subscription_plan.servicing_status : subscription_plan['servicing_status']
|
||||
extract_attribute(subscription_plan, :servicing_status)
|
||||
end
|
||||
|
||||
def cancel_subscription
|
||||
@@ -78,26 +71,20 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
end
|
||||
|
||||
def extract_pricing_plan_id(subscription)
|
||||
# Extract pricing_plan from the subscription object
|
||||
subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan']
|
||||
extract_attribute(subscription, :pricing_plan)
|
||||
end
|
||||
|
||||
def extract_billing_cadence(subscription)
|
||||
subscription.respond_to?(:billing_cadence) ? subscription.billing_cadence : subscription['billing_cadence']
|
||||
extract_attribute(subscription, :billing_cadence)
|
||||
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
|
||||
subscribed_quantity = account.custom_attributes['subscribed_quantity']
|
||||
if subscribed_quantity.present? && subscribed_quantity.to_i.positive?
|
||||
Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{subscribed_quantity}"
|
||||
return subscribed_quantity.to_i
|
||||
end
|
||||
pending_quantity = custom_attribute('pending_subscription_quantity')
|
||||
return pending_quantity.to_i if pending_quantity.present? && pending_quantity.to_i.positive?
|
||||
|
||||
return subscribed_quantity if subscribed_quantity.positive?
|
||||
|
||||
1
|
||||
end
|
||||
|
||||
@@ -113,7 +100,8 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
'pending_subscription_pricing_plan' => nil,
|
||||
'billing_cadence' => billing_cadence,
|
||||
'next_billing_date' => nil,
|
||||
'pending_stripe_pricing_plan_id' => nil
|
||||
'pending_stripe_pricing_plan_id' => nil,
|
||||
'stripe_billing_version' => 2
|
||||
}
|
||||
attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present?
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
|
||||
def validate_topup_request(credits)
|
||||
return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive?
|
||||
|
||||
# Check if account has a valid subscription plan
|
||||
plan_validation = validate_subscription_plan
|
||||
return plan_validation unless plan_validation[:valid]
|
||||
|
||||
@@ -27,7 +26,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
|
||||
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?
|
||||
|
||||
# Check if customer has a default payment method using common service
|
||||
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
|
||||
payment_method_validation = payment_service.validate_payment_method
|
||||
return payment_method_validation.merge(valid: false) if payment_method_validation
|
||||
@@ -102,8 +100,7 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
|
||||
# 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
|
||||
credit_grant_params(amount_cents, currency, credits)
|
||||
)
|
||||
end
|
||||
|
||||
@@ -145,12 +142,4 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
|
||||
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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService
|
||||
def report(credits_used, _feature = nil)
|
||||
return { success: false, message: 'V2 billing not enabled' } unless v2_enabled?
|
||||
return { success: false, message: 'Missing Stripe configuration' } unless valid_configuration?
|
||||
|
||||
event = Stripe::Billing::MeterEvent.create(
|
||||
@@ -16,7 +15,7 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
|
||||
private
|
||||
|
||||
def valid_configuration?
|
||||
custom_attribute('stripe_customer_id').present?
|
||||
stripe_customer_id.present?
|
||||
end
|
||||
|
||||
def meter_event_params(credits_used)
|
||||
@@ -24,7 +23,7 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
|
||||
event_name: 'chatwoot.usage',
|
||||
payload: {
|
||||
value: credits_used.to_s,
|
||||
stripe_customer_id: custom_attribute('stripe_customer_id')
|
||||
stripe_customer_id: stripe_customer_id
|
||||
},
|
||||
identifier: "#{account.id}_#{Time.current.to_i}_#{SecureRandom.hex(4)}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Billing::CreditSyncJob, type: :job do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
before do
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123')
|
||||
allow(InstallationConfig).to receive(:find_by).and_call_original
|
||||
allow(InstallationConfig).to receive(:find_by).with(name: 'STRIPE_METER_ID')
|
||||
.and_return(instance_double(InstallationConfig, value: 'mtr_test_123'))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with no arguments' do
|
||||
let!(:account) do
|
||||
create(:account, custom_attributes: {
|
||||
'stripe_customer_id' => 'cus_123',
|
||||
'stripe_billing_version' => 2,
|
||||
'captain_responses_usage' => 100,
|
||||
'stripe_last_synced_credits' => 50
|
||||
})
|
||||
end
|
||||
|
||||
it 'syncs all accounts with Stripe customer ID' do
|
||||
usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService)
|
||||
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter)
|
||||
allow(usage_reporter).to receive(:report).with(50).and_return({ success: true, event_id: 'evt_123' })
|
||||
|
||||
result = described_class.new.perform
|
||||
|
||||
expect(result).to eq({ synced: 1, failed: 0 })
|
||||
expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with account argument' do
|
||||
let(:account) do
|
||||
create(:account, custom_attributes: {
|
||||
'stripe_customer_id' => 'cus_123',
|
||||
'captain_responses_usage' => 100,
|
||||
'stripe_last_synced_credits' => 30
|
||||
})
|
||||
end
|
||||
|
||||
it 'syncs single account' do
|
||||
usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService)
|
||||
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter)
|
||||
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' })
|
||||
|
||||
result = described_class.new.perform(account)
|
||||
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:credits_reported]).to eq(70)
|
||||
expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,39 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Ai::CaptainCreditService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).and_return(credit_service)
|
||||
end
|
||||
|
||||
describe '#check_and_use_credits' do
|
||||
context 'when account is not on v2 billing' do
|
||||
it 'returns success without invoking credit service' do
|
||||
result = service.check_and_use_credits
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account uses v2 billing' do
|
||||
before do
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
|
||||
allow(credit_service).to receive(:use_credit).and_return(success: true)
|
||||
end
|
||||
|
||||
it 'delegates to credit management service with metadata' do
|
||||
metadata = { 'feature' => 'ai_reply', 'conversation_id' => 123 }
|
||||
|
||||
result = service.check_and_use_credits(feature: 'ai_reply', metadata: metadata)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(credit_service).to have_received(:use_credit)
|
||||
.with(feature: 'ai_reply', amount: 1, metadata: metadata)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,42 +7,15 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
let!(:admin1) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe '#perform' 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')
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
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
|
||||
)
|
||||
context 'when V2 configs are missing' do
|
||||
it 'raises a configuration error' do
|
||||
expect do
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
end.to raise_error(StandardError, /V2 billing configuration is required/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when customer already exists' do
|
||||
before do
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_customer' })
|
||||
end
|
||||
|
||||
it 'does not create a new customer' do
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
# Stub the subscription check to return no subscriptions
|
||||
subscriptions_response = OpenStruct.new(data: [])
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_customer')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with V2 billing enabled' do
|
||||
context 'with V2 billing' do
|
||||
let(:cloud_plans_config) do
|
||||
create(:installation_config,
|
||||
name: 'CHATWOOT_CLOUD_PLANS',
|
||||
@@ -65,8 +38,6 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
# Setup configs
|
||||
cloud_plans_config
|
||||
hacker_plan_config
|
||||
# Enable V2 billing
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('true')
|
||||
end
|
||||
|
||||
it 'creates a stripe customer and sets up V2 billing' do
|
||||
@@ -83,6 +54,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => 'cus_random_number',
|
||||
'stripe_billing_version' => 'v2',
|
||||
'stripe_pricing_plan_id' => 'bpp_hacker_123',
|
||||
'plan_name' => 'Hacker',
|
||||
'subscribed_quantity' => 2
|
||||
@@ -107,6 +79,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => 'cus_existing_v2',
|
||||
'stripe_billing_version' => 'v2',
|
||||
'stripe_pricing_plan_id' => 'bpp_hacker_123',
|
||||
'plan_name' => 'Hacker',
|
||||
'subscribed_quantity' => 2
|
||||
@@ -128,48 +101,5 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_v2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with V1 billing (traditional subscription)' do
|
||||
let(:cloud_plans_config) do
|
||||
create(:installation_config,
|
||||
name: 'CHATWOOT_CLOUD_PLANS',
|
||||
value: [
|
||||
{
|
||||
'name' => 'Startup',
|
||||
'price_ids' => ['price_startup_123'],
|
||||
'default_quantity' => 2
|
||||
}
|
||||
])
|
||||
end
|
||||
|
||||
before do
|
||||
cloud_plans_config
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('false')
|
||||
end
|
||||
|
||||
it 'creates a stripe customer and traditional subscription' do
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
|
||||
subscription = {
|
||||
'plan' => { 'id' => 'price_startup_123', 'product' => 'prod_startup' },
|
||||
'quantity' => 2
|
||||
}
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(subscription)
|
||||
|
||||
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)
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => 'cus_random_number',
|
||||
'stripe_price_id' => 'price_startup_123',
|
||||
'stripe_product_id' => 'prod_startup',
|
||||
'plan_name' => 'Startup',
|
||||
'subscribed_quantity' => 2
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,104 +6,26 @@ describe Enterprise::Billing::V2::CreditManagementService do
|
||||
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts',
|
||||
'captain_responses_monthly' => 100,
|
||||
'captain_responses_topup' => 50,
|
||||
'captain_responses_usage' => 0,
|
||||
'captain_documents_usage' => 0
|
||||
},
|
||||
limits: {
|
||||
'captain_responses' => 150,
|
||||
'captain_documents' => 200
|
||||
}
|
||||
custom_attributes: { 'captain_responses_usage' => 0 },
|
||||
limits: { 'captain_responses_monthly' => 100, 'captain_responses_topup' => 50 }
|
||||
)
|
||||
end
|
||||
|
||||
describe '#sync_monthly_response_credits' do
|
||||
it 'updates monthly credits and syncs total' do
|
||||
it 'updates monthly credits' do
|
||||
service.sync_monthly_response_credits(500)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_monthly']).to eq(500)
|
||||
expect(account.limits['captain_responses']).to eq(550) # 500 + 50 topup (stored in limits ONLY)
|
||||
end
|
||||
|
||||
it 'preserves topup credits capped at remaining balance' do
|
||||
# Set usage to 80
|
||||
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 80))
|
||||
|
||||
service.sync_monthly_response_credits(100)
|
||||
|
||||
account.reload
|
||||
# Current: monthly=100, topup=50, total=150, usage=80
|
||||
# Remaining: 150 - 80 = 70
|
||||
# Topup should be capped at min(50, 70) = 50
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(50)
|
||||
expect(account.limits['captain_responses']).to eq(150)
|
||||
end
|
||||
|
||||
it 'caps topup when usage exceeds new monthly allocation' do
|
||||
# Usage is 120, new monthly is 100, topup is 50
|
||||
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 120))
|
||||
|
||||
service.sync_monthly_response_credits(100)
|
||||
|
||||
account.reload
|
||||
# New total would be: monthly=100 + topup=50 = 150
|
||||
# Usage: 120
|
||||
# Remaining: 150 - 120 = 30
|
||||
# Topup should be capped at min(50, 30) = 30
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(30)
|
||||
expect(account.limits['captain_responses']).to eq(130) # 100 + 30
|
||||
end
|
||||
|
||||
it 'zeros topup when usage exceeds new total' do
|
||||
# Usage is 160, new monthly is 100, topup is 50
|
||||
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 160))
|
||||
|
||||
service.sync_monthly_response_credits(100)
|
||||
|
||||
account.reload
|
||||
# New total would be: monthly=100 + topup=50 = 150
|
||||
# Usage: 160 (exceeds total)
|
||||
# Remaining: max(0, 150 - 160) = 0
|
||||
# Topup should be: min(50, 0) = 0
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(0)
|
||||
expect(account.limits['captain_responses']).to eq(100) # 100 + 0
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_document_credits' do
|
||||
it 'updates document credits total' do
|
||||
service.sync_document_credits(500)
|
||||
|
||||
account.reload
|
||||
expect(account.limits['captain_documents']).to eq(500) # Stored in limits hash ONLY
|
||||
end
|
||||
end
|
||||
|
||||
describe '#expire_monthly_response_credits' do
|
||||
it 'expires monthly credits and preserves topup' do
|
||||
expired = service.expire_monthly_response_credits
|
||||
|
||||
expect(expired).to eq(100)
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_monthly']).to eq(0)
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(50)
|
||||
expect(account.limits['captain_responses']).to eq(50)
|
||||
expect(account.reload.limits['captain_responses_monthly']).to eq(500)
|
||||
expect(account.limits['captain_responses']).to eq(550)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_response_topup_credits' do
|
||||
it 'adds topup credits and updates total' do
|
||||
it 'adds topup credits' do
|
||||
service.add_response_topup_credits(100)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(150) # 50 + 100
|
||||
expect(account.limits['captain_responses']).to eq(250) # 100 monthly + 150 topup
|
||||
expect(account.reload.limits['captain_responses_topup']).to eq(150)
|
||||
expect(account.limits['captain_responses']).to eq(250)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Billing::V2::PricingPlanService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123')
|
||||
end
|
||||
|
||||
describe '#create_custom_pricing_unit' do
|
||||
it 'creates a custom pricing unit' do
|
||||
cpu_response = OpenStruct.new(id: 'cpu_123')
|
||||
allow(StripeV2Client).to receive(:request).and_return(cpu_response)
|
||||
|
||||
result = service.create_custom_pricing_unit(display_name: 'Credits', lookup_key: 'credits_001')
|
||||
|
||||
expect(result.id).to eq('cpu_123')
|
||||
expect(StripeV2Client).to have_received(:request).with(
|
||||
:post,
|
||||
'/v2/billing/custom_pricing_units',
|
||||
{ display_name: 'Credits', lookup_key: 'credits_001' },
|
||||
{ api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_meter' do
|
||||
it 'creates a billing meter' do
|
||||
meter_response = OpenStruct.new(id: 'meter_123')
|
||||
allow(StripeV2Client).to receive(:request).and_return(meter_response)
|
||||
|
||||
result = service.create_meter(display_name: 'Prompts', event_name: 'prompts_001')
|
||||
|
||||
expect(result.id).to eq('meter_123')
|
||||
expect(StripeV2Client).to have_received(:request).with(
|
||||
:post,
|
||||
'/v1/billing/meters',
|
||||
kind_of(Hash),
|
||||
{ api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_pricing_plan' do
|
||||
it 'creates a pricing plan' 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', 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', 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',
|
||||
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_unit_label: 'per agent',
|
||||
license_fee_display_name: 'Fee',
|
||||
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',
|
||||
rate_value: 1
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:pricing_plan]).to eq(plan)
|
||||
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
|
||||
+22
-169
@@ -7,7 +7,7 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do
|
||||
let(:pricing_plan_id) { 'bpp_business_plan_123' }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
|
||||
account.update!(custom_attributes: { 'pending_subscription_quantity' => 5 })
|
||||
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
|
||||
end
|
||||
|
||||
@@ -16,9 +16,8 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do
|
||||
OpenStruct.new(
|
||||
id: subscription_id,
|
||||
pricing_plan: pricing_plan_id,
|
||||
component_values: [
|
||||
{ 'type' => 'license_fee', 'quantity' => 5 }
|
||||
]
|
||||
billing_cadence: 'bpc_cadence_123',
|
||||
servicing_status: 'active'
|
||||
)
|
||||
end
|
||||
|
||||
@@ -26,179 +25,33 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do
|
||||
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
|
||||
end
|
||||
|
||||
it 'retrieves subscription details from Stripe' do
|
||||
service.provision(subscription_id: subscription_id)
|
||||
it 'provisions subscription' do
|
||||
result = 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')
|
||||
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
|
||||
expect(result[:quantity]).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#refresh' do
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: 'bpps_sub_456',
|
||||
pricing_plan: pricing_plan_id,
|
||||
billing_cadence: 'bpc_cadence_123',
|
||||
servicing_status: 'active'
|
||||
)
|
||||
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')
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_subscription_id' => 'bpps_sub_456' })
|
||||
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
|
||||
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)
|
||||
it 'refreshes subscription' do
|
||||
result = service.refresh
|
||||
|
||||
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 '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
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
require 'rails_helper'
|
||||
require 'ostruct'
|
||||
|
||||
describe Enterprise::Billing::V2::TopupService do
|
||||
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Startups' }) }
|
||||
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Business' }) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:invoice_settings) { instance_double(Stripe::InvoiceSettings, default_payment_method: 'pm_123') }
|
||||
let(:stripe_customer) do
|
||||
instance_double(Stripe::Customer,
|
||||
invoice_settings: invoice_settings,
|
||||
default_source: nil)
|
||||
end
|
||||
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(
|
||||
@@ -35,110 +10,26 @@ describe Enterprise::Billing::V2::TopupService do
|
||||
amount: 50.0,
|
||||
currency: 'usd'
|
||||
)
|
||||
|
||||
# Mock Stripe Customer retrieval for payment method check
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer)
|
||||
|
||||
# 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)
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(
|
||||
OpenStruct.new(invoice_settings: OpenStruct.new(default_payment_method: 'pm_123'), default_source: nil)
|
||||
)
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(Stripe::Invoice.construct_from(id: 'in_123'))
|
||||
allow(Stripe::InvoiceItem).to receive(:create)
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice).and_return(
|
||||
Stripe::Invoice.construct_from(id: 'in_123', status: 'open')
|
||||
)
|
||||
allow(Stripe::Invoice).to receive(:pay).and_return(
|
||||
Stripe::Invoice.construct_from(id: 'in_123', status: 'paid', total: 5000, hosted_invoice_url: 'https://invoice.stripe.com')
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:create).and_return({ 'id' => 'credgr_123' })
|
||||
end
|
||||
|
||||
describe '#create_topup' do
|
||||
context 'when successful' do
|
||||
before do
|
||||
service.create_topup(credits: 500)
|
||||
end
|
||||
it 'creates topup' do
|
||||
result = service.create_topup(credits: 500)
|
||||
|
||||
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')
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:credits]).to eq(500)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,55 +1,18 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::V2::UsageReporterService do
|
||||
let(:account) { create(:account) }
|
||||
let(:account) { create(:account, custom_attributes: { 'stripe_billing_version' => 2, 'stripe_customer_id' => 'cus_123' }) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts'
|
||||
}
|
||||
)
|
||||
end
|
||||
describe '#report' do
|
||||
it 'posts usage to Stripe' do
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(OpenStruct.new(identifier: 'me_123'))
|
||||
|
||||
it 'posts usage events to Stripe meters' do
|
||||
meter_event = OpenStruct.new(identifier: 'me_test_123')
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(meter_event)
|
||||
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: true, event_id: 'me_test_123')
|
||||
expect(Stripe::Billing::MeterEvent).to have_received(:create) do |params, options|
|
||||
expect(params[:event_name]).to eq('ai_prompts')
|
||||
expect(params[:payload][:value]).to eq('5')
|
||||
expect(params[:payload][:stripe_customer_id]).to eq('cus_test_123')
|
||||
expect(options[:stripe_version]).to eq('2025-08-27.preview')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when V2 billing not enabled' do
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 1 })
|
||||
end
|
||||
|
||||
it 'returns error' do
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: false, message: 'V2 billing not enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no Stripe customer' do
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2 })
|
||||
end
|
||||
|
||||
it 'returns error' do
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: false, message: 'Missing Stripe configuration')
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:event_id]).to eq('me_123')
|
||||
expect(Stripe::Billing::MeterEvent).to have_received(:create)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,105 +1,40 @@
|
||||
require 'rails_helper'
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
|
||||
describe Enterprise::Billing::V2::WebhookHandlerService do
|
||||
let(:account) do
|
||||
create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
|
||||
end
|
||||
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) }
|
||||
let(:service) { described_class.new }
|
||||
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)
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
end
|
||||
let(:pricing_plan_id) { 'bpp_plan_123' }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when handling subscription servicing activated' do
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: 'bpps_subscription_123',
|
||||
pricing_plan: 'bpp_business_plan_123',
|
||||
billing_cadence: 'cadence_123',
|
||||
component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }]
|
||||
)
|
||||
end
|
||||
|
||||
let(:cadence_response) do
|
||||
OpenStruct.new(
|
||||
id: 'cadence_123',
|
||||
payer: OpenStruct.new(customer: 'cus_123')
|
||||
)
|
||||
end
|
||||
|
||||
let(:provisioning_response) do
|
||||
{
|
||||
success: true,
|
||||
subscription_id: 'bpps_subscription_123',
|
||||
pricing_plan_id: 'bpp_business_plan_123',
|
||||
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')
|
||||
|
||||
# Mock account lookup via subscription and cadence
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
|
||||
.and_return(subscription_response)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
|
||||
.and_return(cadence_response)
|
||||
|
||||
# Mock provisioning service
|
||||
provisioning_service = instance_double(Enterprise::Billing::V2::SubscriptionProvisioningService)
|
||||
allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new).with(account: account).and_return(provisioning_service)
|
||||
allow(provisioning_service).to receive(:provision).and_return(provisioning_response)
|
||||
|
||||
allow(credit_service).to receive(:sync_monthly_credits)
|
||||
end
|
||||
|
||||
it 'delegates to subscription provisioning service' do
|
||||
result = service.perform(event: event)
|
||||
|
||||
expect(result[:success]).to be(true), "Expected success but got: #{result.inspect}"
|
||||
expect(Enterprise::Billing::V2::SubscriptionProvisioningService).to have_received(:new).with(account: account)
|
||||
end
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: 'bpps_sub_123',
|
||||
billing_cadence: 'cad_123',
|
||||
pricing_plan: pricing_plan_id,
|
||||
servicing_status: 'active'
|
||||
)
|
||||
end
|
||||
let(:cadence_response) { OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123')) }
|
||||
let(:event) do
|
||||
double('Stripe::Event', type: 'v2.billing.pricing_plan_subscription.servicing_activated', related_object: OpenStruct.new(id: 'bpps_sub_123')) # rubocop:disable RSpec/VerifiedDoubles
|
||||
end
|
||||
|
||||
context 'when handling unknown event' do
|
||||
it 'returns success' do
|
||||
related_object = OpenStruct.new(id: 'bpps_subscription_123')
|
||||
event = double('Stripe::Event', type: 'unknown.event', related_object: related_object)
|
||||
before do
|
||||
account # Ensure account is created
|
||||
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
|
||||
# Mock the Stripe API calls
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_sub_123', anything, anything)
|
||||
.and_return(subscription_response)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/cadences/cad_123', anything, anything)
|
||||
.and_return(cadence_response)
|
||||
end
|
||||
|
||||
# Mock account lookup
|
||||
subscription_response = OpenStruct.new(billing_cadence: 'cadence_123')
|
||||
cadence_response = OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123'))
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
|
||||
.and_return(subscription_response)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
|
||||
.and_return(cadence_response)
|
||||
it 'handles subscription activation' do
|
||||
result = service.perform(event: event)
|
||||
|
||||
result = service.perform(event: event)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
end
|
||||
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
|
||||
Reference in New Issue
Block a user