Merge remote-tracking branch 'origin/stripe_v2/cancel_subscription' into stripe_v2/topup
This commit is contained in:
@@ -8,7 +8,10 @@ module BillingHelper
|
||||
# Return false if not plans are configured, so that no checks are enforced
|
||||
return false if default_plan.blank?
|
||||
|
||||
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
|
||||
# Handle both string and hash formats for default_plan
|
||||
default_plan_name = default_plan.is_a?(Hash) ? default_plan['name'] : default_plan
|
||||
|
||||
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan_name
|
||||
end
|
||||
|
||||
def conversations_this_month(account)
|
||||
|
||||
@@ -119,6 +119,12 @@ en:
|
||||
invalid_token: Invalid or expired MFA token
|
||||
invalid_credentials: Invalid credentials or verification code
|
||||
feature_unavailable: MFA feature is not available. Please configure encryption keys.
|
||||
enterprise:
|
||||
billing:
|
||||
topup_amount_invalid: Topup amount must be greater than 0
|
||||
stripe_customer_required: Customer ID required. Please create a Stripe customer first.
|
||||
lookup_key_not_found: Lookup key not found for pricing plan %{pricing_plan_id}
|
||||
v2_configuration_required: V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.
|
||||
profile:
|
||||
mfa:
|
||||
enabled: MFA enabled successfully
|
||||
@@ -435,3 +441,8 @@ en:
|
||||
subject: 'Finish setting up %{custom_domain}'
|
||||
ssl_status:
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
enterprise:
|
||||
billing:
|
||||
topup_successful: Topup successful
|
||||
subscription_cancelled: Subscription cancelled
|
||||
pricing_plan_changed: Pricing plan changed
|
||||
|
||||
+11
-5
@@ -431,12 +431,18 @@ Rails.application.routes.draw do
|
||||
post :subscription
|
||||
get :limits
|
||||
post :toggle_deletion
|
||||
# V2 Billing endpoints
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :v2 do
|
||||
resources :accounts, only: [] do
|
||||
resource :billing, only: [] do
|
||||
get :credit_grants
|
||||
get :v2_pricing_plans
|
||||
get :v2_topup_options
|
||||
post :v2_topup
|
||||
post :v2_subscribe
|
||||
get :pricing_plans
|
||||
get :topup_options
|
||||
post :topup
|
||||
post :subscribe
|
||||
post :cancel_subscription
|
||||
post :change_pricing_plan
|
||||
end
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
module Enterprise::Api::V1::Accounts::Concerns::BillingV2
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :validate_topup_amount, only: [:v2_topup]
|
||||
end
|
||||
|
||||
def credit_grants
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
|
||||
grants = service.fetch_credit_grants
|
||||
|
||||
render json: { credit_grants: grants }
|
||||
end
|
||||
|
||||
def v2_pricing_plans
|
||||
plans = Enterprise::Billing::V2::PlanCatalog.plans
|
||||
render json: { pricing_plans: plans }
|
||||
end
|
||||
|
||||
def v2_topup_options
|
||||
options = Enterprise::Billing::V2::TopupCatalog.options
|
||||
render json: { topup_options: options }
|
||||
end
|
||||
|
||||
def v2_topup
|
||||
service = Enterprise::Billing::V2::TopupService.new(account: @account)
|
||||
result = service.create_topup(credits: params[:credits].to_i)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, message: result[:message] }
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def v2_subscribe
|
||||
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
|
||||
result = service.create_subscription_checkout(
|
||||
pricing_plan_id: params[:pricing_plan_id],
|
||||
quantity: subscription_quantity
|
||||
)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, redirect_url: result[:redirect_url], session_id: result[:session_id] }
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def cancel_subscription
|
||||
render json: { success: true, message: 'Subscription cancelled.' }
|
||||
end
|
||||
|
||||
def change_pricing_plan
|
||||
render json: { success: true, message: 'Pricing plan changed.' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def subscription_quantity
|
||||
[params[:quantity].to_i, 1].max
|
||||
end
|
||||
|
||||
def validate_topup_amount
|
||||
return if params[:credits].to_i.positive?
|
||||
|
||||
render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,5 @@
|
||||
class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
include BillingHelper
|
||||
include Enterprise::Api::V1::Accounts::Concerns::BillingV2
|
||||
|
||||
before_action :fetch_account
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
class Enterprise::Api::V2::BillingController < Api::BaseController
|
||||
before_action :fetch_account
|
||||
before_action :check_authorization
|
||||
before_action :validate_topup_amount, only: [:topup]
|
||||
|
||||
rescue_from StandardError, with: :render_error
|
||||
rescue_from NotImplementedError, with: :render_not_implemented
|
||||
|
||||
def credit_grants
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
|
||||
grants = service.fetch_credit_grants
|
||||
|
||||
render json: { credit_grants: grants }
|
||||
end
|
||||
|
||||
def pricing_plans
|
||||
plans = Enterprise::Billing::V2::PlanCatalog.plans
|
||||
render json: { pricing_plans: plans }
|
||||
end
|
||||
|
||||
def topup_options
|
||||
options = Enterprise::Billing::V2::TopupCatalog.options
|
||||
render json: { topup_options: options }
|
||||
end
|
||||
|
||||
def topup
|
||||
service = Enterprise::Billing::V2::TopupService.new(account: @account)
|
||||
result = service.create_topup(credits: params[:credits].to_i)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, message: result[:message] }
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def subscribe
|
||||
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
|
||||
redirect_url = service.create_subscription_checkout(
|
||||
pricing_plan_id: params[:pricing_plan_id],
|
||||
quantity: subscription_quantity
|
||||
)
|
||||
|
||||
render json: { redirect_url: redirect_url }
|
||||
end
|
||||
|
||||
def cancel_subscription
|
||||
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
|
||||
result = service.cancel_subscription
|
||||
|
||||
if result[:success]
|
||||
# Include account ID and updated attributes for frontend store update
|
||||
@account.reload
|
||||
render json: result.merge(
|
||||
id: @account.id,
|
||||
custom_attributes: @account.custom_attributes
|
||||
)
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def change_pricing_plan
|
||||
service = Enterprise::Billing::V2::ChangePlanService.new(account: @account)
|
||||
result = service.change_plan(
|
||||
new_pricing_plan_id: params[:pricing_plan_id],
|
||||
quantity: params[:quantity]&.to_i
|
||||
)
|
||||
|
||||
if result[:success]
|
||||
# Include account ID and updated attributes for frontend store update
|
||||
@account.reload
|
||||
render json: result.merge(
|
||||
id: @account.id,
|
||||
custom_attributes: @account.custom_attributes
|
||||
)
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_account
|
||||
@account = current_user.accounts.find(params[:account_id])
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
|
||||
def subscription_quantity
|
||||
[params[:quantity].to_i, 1].max
|
||||
end
|
||||
|
||||
def validate_topup_amount
|
||||
return if params[:credits].to_i.positive?
|
||||
|
||||
render json: { error: I18n.t('errors.enterprise.billing.topup_amount_invalid') }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def pundit_user
|
||||
{
|
||||
user: current_user,
|
||||
account: @account,
|
||||
account_user: @current_account_user
|
||||
}
|
||||
end
|
||||
|
||||
def render_error(exception)
|
||||
render json: { error: exception.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def render_not_implemented(exception)
|
||||
render json: { error: exception.message }, status: :not_implemented
|
||||
end
|
||||
end
|
||||
@@ -35,6 +35,8 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
parsed_payload = JSON.parse(payload)
|
||||
event_type = parsed_payload['type']
|
||||
|
||||
return ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) if event_type.blank?
|
||||
|
||||
if v2_billing_event?(event_type)
|
||||
ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil)
|
||||
else
|
||||
@@ -43,6 +45,8 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
end
|
||||
|
||||
def v2_billing_event?(event_type)
|
||||
return false if event_type.blank?
|
||||
|
||||
Rails.logger.debug { "V2 billing event: #{event_type}" }
|
||||
event_type.start_with?('v2.')
|
||||
end
|
||||
|
||||
@@ -46,10 +46,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
end
|
||||
|
||||
def enable_features_for_current_plan(plan_name)
|
||||
# First disable all premium features to handle downgrades
|
||||
disable_all_premium_features
|
||||
|
||||
# Then enable features based on the current plan
|
||||
enable_plan_specific_features(plan_name)
|
||||
end
|
||||
|
||||
@@ -58,7 +55,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
# Enable features based on plan hierarchy
|
||||
case plan_name
|
||||
when 'Startup', 'Startups'
|
||||
when 'Startups'
|
||||
# Startup plan gets the basic features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
|
||||
@@ -4,39 +4,10 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||
private
|
||||
|
||||
def provision_new_plan(new_pricing_plan_id)
|
||||
sync_plan_credits(new_pricing_plan_id)
|
||||
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
|
||||
return unless plan_definition
|
||||
|
||||
plan_name = extract_plan_name(plan_definition)
|
||||
plan_name = plan_definition[:display_name]
|
||||
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||
end
|
||||
|
||||
def sync_plan_credits(pricing_plan_id)
|
||||
plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id)
|
||||
|
||||
Enterprise::Billing::V2::CreditManagementService
|
||||
.new(account: account)
|
||||
.sync_monthly_response_credits(plan_credits.to_i)
|
||||
end
|
||||
|
||||
def extract_plan_name(plan_definition)
|
||||
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
|
||||
end
|
||||
|
||||
def update_account_plan(new_pricing_plan_id, quantity, next_billing_date)
|
||||
attributes = {
|
||||
'stripe_pricing_plan_id' => new_pricing_plan_id,
|
||||
'pending_stripe_pricing_plan_id' => nil,
|
||||
'pending_subscription_quantity' => nil,
|
||||
'subscribed_quantity' => quantity,
|
||||
'next_billing_date' => next_billing_date
|
||||
}
|
||||
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
|
||||
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
|
||||
|
||||
update_custom_attributes(attributes)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,6 +60,8 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
end
|
||||
|
||||
def extract_attribute(object, key)
|
||||
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
|
||||
return object.public_send(key) if object.respond_to?(key)
|
||||
|
||||
object[key.to_s]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,7 +35,7 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
end
|
||||
|
||||
def raise_config_error
|
||||
raise StandardError, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
|
||||
raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required')
|
||||
end
|
||||
|
||||
def existing_subscription?
|
||||
|
||||
@@ -89,14 +89,29 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
amount = extract_credit_amount(grant)
|
||||
return if amount.zero?
|
||||
|
||||
grant_type = extract_grant_type(grant)
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
service.add_response_topup_credits(amount)
|
||||
if grant_type == 'monetary'
|
||||
service.add_response_topup_credits(amount)
|
||||
else
|
||||
service.sync_monthly_response_credits(amount)
|
||||
end
|
||||
end
|
||||
|
||||
def extract_credit_grant_id(grant_object)
|
||||
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
|
||||
end
|
||||
|
||||
def extract_grant_type(grant)
|
||||
amount_object = extract_attribute(grant, :amount)
|
||||
return 'monetary' if amount_object.blank?
|
||||
|
||||
type = extract_attribute(amount_object, :type)
|
||||
return 'monetary' if type.blank?
|
||||
|
||||
type
|
||||
end
|
||||
|
||||
def extract_credit_amount(grant)
|
||||
# First, try to get credits from metadata
|
||||
metadata = extract_attribute(grant, :metadata)
|
||||
@@ -104,23 +119,19 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
credits = extract_attribute(metadata, :credits)
|
||||
return credits.to_i if credits.present? && credits.to_i.positive?
|
||||
end
|
||||
amount = extract_attribute(grant, :amount)
|
||||
return 0 if amount.blank?
|
||||
|
||||
# Fallback: extract from amount object
|
||||
amount_data = extract_attribute(grant, :amount)
|
||||
return 0 unless amount_data
|
||||
custom_pricing_unit = extract_attribute(amount, :custom_pricing_unit)
|
||||
return 0 if custom_pricing_unit.blank?
|
||||
|
||||
0
|
||||
value = extract_attribute(custom_pricing_unit, :value)
|
||||
return 0 if value.blank?
|
||||
|
||||
value.to_i
|
||||
end
|
||||
|
||||
def extract_attribute(object, attribute)
|
||||
object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s]
|
||||
end
|
||||
|
||||
def extract_amount_value(amount_data, unit_type)
|
||||
unit = extract_attribute(amount_data, unit_type)
|
||||
return 0 unless unit
|
||||
|
||||
value = extract_attribute(unit, :value)
|
||||
value.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,7 +28,7 @@ class Enterprise::Billing::V2::BaseService
|
||||
# Update response credits (monthly/topup with auto-calculation of total)
|
||||
def update_response_credits(monthly: nil, topup: nil)
|
||||
# Calculate and update total in limits hash ONLY
|
||||
return unless monthly || topup
|
||||
return if monthly.nil? && topup.nil?
|
||||
|
||||
new_monthly = monthly || response_monthly_credits
|
||||
new_topup = topup || response_topup_credits
|
||||
@@ -44,23 +44,13 @@ class Enterprise::Billing::V2::BaseService
|
||||
def update_limits(updates)
|
||||
return if updates.blank?
|
||||
|
||||
current_limits = account.limits.present? ? account.limits.deep_dup : {}
|
||||
updates.each do |key, value|
|
||||
current_limits[key.to_s] = value
|
||||
end
|
||||
|
||||
account.update!(limits: current_limits)
|
||||
account.update!(limits: (account.limits || {}).merge(updates.transform_keys(&:to_s)))
|
||||
end
|
||||
|
||||
def update_custom_attributes(updates)
|
||||
return if updates.blank?
|
||||
|
||||
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
|
||||
updates.each do |key, value|
|
||||
current_attributes[key.to_s] = value
|
||||
end
|
||||
|
||||
account.update!(custom_attributes: current_attributes)
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge(updates.transform_keys(&:to_s)))
|
||||
end
|
||||
|
||||
def custom_attribute(key)
|
||||
|
||||
@@ -139,4 +139,16 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def update_account_plan(plan_id, quantity, next_billing_date)
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)
|
||||
plan_name = plan_definition&.dig(:display_name)
|
||||
|
||||
update_custom_attributes({
|
||||
'pricing_plan_id' => plan_id,
|
||||
'subscribed_quantity' => quantity,
|
||||
'plan_name' => plan_name,
|
||||
'next_billing_date' => next_billing_date
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,13 +13,13 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
|
||||
validate_params
|
||||
store_pending_subscription_quantity
|
||||
session = create_checkout_session(checkout_session_params)
|
||||
{ success: true, redirect_url: session.url }
|
||||
session.url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_params
|
||||
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank?
|
||||
raise StandardError, I18n.t('errors.enterprise.billing.stripe_customer_required') if stripe_customer_id.blank?
|
||||
end
|
||||
|
||||
def store_pending_subscription_quantity
|
||||
@@ -50,7 +50,7 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
|
||||
|
||||
def build_checkout_items
|
||||
lookup_key = extract_license_lookup_key
|
||||
raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key
|
||||
raise StandardError, I18n.t('errors.enterprise.billing.lookup_key_not_found', pricing_plan_id: @pricing_plan_id) unless lookup_key
|
||||
|
||||
[
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
end
|
||||
grants.reject { |grant| grant[:credits].zero? }
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error("Failed to fetch credit grants: #{e.message}")
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
[]
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
|
||||
|
||||
# Validate that customer has a default payment method
|
||||
# If no default is set but payment methods exist, automatically set the first one as default
|
||||
# @return [Hash, nil] Returns error hash if validation fails, nil if success
|
||||
def validate_payment_method
|
||||
return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank?
|
||||
@@ -16,10 +17,9 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||
|
||||
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
|
||||
return {
|
||||
success: false,
|
||||
message: 'No default payment method found. Please add a default payment method before making a purchase.'
|
||||
}
|
||||
# No default payment method found - try to set one automatically
|
||||
ensure_default_payment_method_result = ensure_default_payment_method(customer)
|
||||
return ensure_default_payment_method_result unless ensure_default_payment_method_result.nil?
|
||||
end
|
||||
|
||||
nil
|
||||
@@ -28,6 +28,40 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::
|
||||
{ success: false, message: "Error validating payment method: #{e.message}" }
|
||||
end
|
||||
|
||||
# Ensure a default payment method is set for the customer
|
||||
# If payment methods exist but none is default, set the first one as default
|
||||
# @param customer [Stripe::Customer] The Stripe customer object
|
||||
# @return [Hash, nil] Returns error hash if no payment methods exist, nil if default is set successfully
|
||||
def ensure_default_payment_method(customer)
|
||||
payment_methods = fetch_customer_payment_methods(customer.id)
|
||||
return no_payment_methods_error if payment_methods.data.empty?
|
||||
|
||||
set_first_payment_method_as_default(customer.id, payment_methods.data.first)
|
||||
nil
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error("Failed to set default payment method: #{e.message}")
|
||||
{ success: false, message: "Error setting default payment method: #{e.message}" }
|
||||
end
|
||||
|
||||
def fetch_customer_payment_methods(customer_id)
|
||||
Stripe::PaymentMethod.list(customer: customer_id, limit: 100)
|
||||
end
|
||||
|
||||
def no_payment_methods_error
|
||||
{
|
||||
success: false,
|
||||
message: 'No payment methods found. Please add a payment method before making a purchase.'
|
||||
}
|
||||
end
|
||||
|
||||
def set_first_payment_method_as_default(customer_id, payment_method)
|
||||
Stripe::Customer.update(
|
||||
customer_id,
|
||||
invoice_settings: { default_payment_method: payment_method.id }
|
||||
)
|
||||
Rails.logger.info("Automatically set payment method #{payment_method.id} as default for customer #{customer_id}")
|
||||
end
|
||||
|
||||
# Create invoice with line items and charge immediately
|
||||
#
|
||||
# @param line_items [Array<Hash>] Line items: [{ amount: (cents), description:, metadata: }]
|
||||
|
||||
@@ -5,7 +5,7 @@ module Enterprise::Billing::V2::PlanCatalog
|
||||
PLAN_DEFINITIONS = [
|
||||
{
|
||||
key: :free,
|
||||
display_name: 'Chatwoot Hacker',
|
||||
display_name: 'Hacker',
|
||||
base_fee: 0.0,
|
||||
monthly_credits: 0,
|
||||
config_key: 'STRIPE_HACKER_PLAN_ID',
|
||||
@@ -13,7 +13,7 @@ module Enterprise::Billing::V2::PlanCatalog
|
||||
},
|
||||
{
|
||||
key: :startup,
|
||||
display_name: 'Chatwoot Startup',
|
||||
display_name: 'Startups',
|
||||
base_fee: 19.0,
|
||||
monthly_credits: 300,
|
||||
config_key: 'STRIPE_STARTUP_PLAN_ID',
|
||||
@@ -21,7 +21,7 @@ module Enterprise::Billing::V2::PlanCatalog
|
||||
},
|
||||
{
|
||||
key: :business,
|
||||
display_name: 'Chatwoot Business',
|
||||
display_name: 'Business',
|
||||
base_fee: 39.0,
|
||||
monthly_credits: 500,
|
||||
config_key: 'STRIPE_BUSINESS_PLAN_ID',
|
||||
@@ -29,7 +29,7 @@ module Enterprise::Billing::V2::PlanCatalog
|
||||
},
|
||||
{
|
||||
key: :enterprise,
|
||||
display_name: 'Chatwoot Enterprise',
|
||||
display_name: 'Enterprise',
|
||||
base_fee: 99.0,
|
||||
monthly_credits: 800,
|
||||
config_key: 'STRIPE_ENTERPRISE_PLAN_ID',
|
||||
@@ -53,11 +53,6 @@ module Enterprise::Billing::V2::PlanCatalog
|
||||
nil
|
||||
end
|
||||
|
||||
def monthly_credits_for(plan_id)
|
||||
definition = definition_for(plan_id)
|
||||
definition ? definition[:monthly_credits] : nil
|
||||
end
|
||||
|
||||
def plan_id_for(definition)
|
||||
InstallationConfig.find_by(name: definition[:config_key])&.value
|
||||
end
|
||||
|
||||
@@ -49,6 +49,8 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
|
||||
def cancel_subscription
|
||||
hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID')
|
||||
return if hacker_plan_config.nil?
|
||||
|
||||
pricing_plan_id = hacker_plan_config.value
|
||||
|
||||
# Update subscription status and plan details
|
||||
@@ -63,7 +65,9 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
update_custom_attributes(attributes)
|
||||
|
||||
# Sync credits for Hacker plan (0 credits)
|
||||
sync_plan_credits(pricing_plan_id)
|
||||
Enterprise::Billing::V2::CreditManagementService
|
||||
.new(account: account)
|
||||
.sync_monthly_response_credits(0)
|
||||
|
||||
# Disable all premium features and save
|
||||
disable_all_premium_features
|
||||
|
||||
@@ -3,9 +3,8 @@ class Enterprise::Billing::V2::WebhookHandlerService
|
||||
|
||||
def perform(event:)
|
||||
@event = event
|
||||
return { success: false, message: 'Event is required' } if @event.blank?
|
||||
|
||||
return { success: false, message: 'Account not found' } if account.blank?
|
||||
raise StandardError, 'Event is required' if @event.blank?
|
||||
raise StandardError, 'Account not found' if account.blank?
|
||||
|
||||
case @event.type
|
||||
when 'v2.billing.pricing_plan_subscription.servicing_activated'
|
||||
@@ -14,12 +13,10 @@ class Enterprise::Billing::V2::WebhookHandlerService
|
||||
when 'v2.billing.cadence.billed'
|
||||
Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}"
|
||||
refresh_account_subscription_details(@event.related_object.id)
|
||||
else
|
||||
{ success: true }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error processing V2 webhook: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -205,8 +205,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
},
|
||||
'conversation' => {},
|
||||
'captain' => {
|
||||
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
|
||||
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit }
|
||||
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
|
||||
'monthly' => nil, 'topup' => nil },
|
||||
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
|
||||
'monthly' => 0, 'topup' => 0 }
|
||||
},
|
||||
'non_web_inboxes' => {}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,34 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
||||
describe 'POST /enterprise/webhooks/stripe' do
|
||||
let(:params) { { content: 'hello' } }
|
||||
let(:payload) { { type: 'customer.subscription.updated', content: 'hello' }.to_json }
|
||||
let(:event_object) { instance_double(Stripe::Event, type: 'customer.subscription.updated') }
|
||||
|
||||
around do |example|
|
||||
ENV['STRIPE_WEBHOOK_SECRET'] = 'test_secret'
|
||||
ENV['STRIPE_WEBHOOK_SECRET_V2'] = 'test_secret_v2'
|
||||
example.run
|
||||
ENV.delete('STRIPE_WEBHOOK_SECRET')
|
||||
ENV.delete('STRIPE_WEBHOOK_SECRET_V2')
|
||||
end
|
||||
|
||||
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
|
||||
handle_stripe = double
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(event_object)
|
||||
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
|
||||
allow(handle_stripe).to receive(:perform)
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
expect(handle_stripe).to have_received(:perform).with(event: params)
|
||||
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
|
||||
expect(handle_stripe).to have_received(:perform).with(event: event_object)
|
||||
end
|
||||
|
||||
it 'returns a bad request if the headers are missing' do
|
||||
post '/enterprise/webhooks/stripe', params: params
|
||||
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json' }
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'returns a bad request if the headers are invalid' do
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_raise(Stripe::SignatureVerificationError.new('Invalid signature', 'sig'))
|
||||
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
+78
-118
@@ -1,139 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
subject(:create_stripe_customer_service) { described_class }
|
||||
subject(:service) { described_class.new(account: account) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:admin1) { create(:user, account: account, role: :administrator) }
|
||||
let(:admin2) { create(:user, account: account, role: :administrator) }
|
||||
let(:subscriptions_list) { double }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:stripe_customer_double) { instance_double(Stripe::Customer, id: 'cus_new') }
|
||||
let(:subscriptions_list) { Stripe::ListObject.construct_from({ data: [] }) }
|
||||
let(:hacker_plan_id) { 'price_hacker_random' }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
|
||||
] }
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not call stripe methods if customer id is present' do
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
|
||||
allow(subscriptions_list).to receive(:data).and_return([])
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
.and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
expect(Stripe::Subscription)
|
||||
.to have_received(:create)
|
||||
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
||||
|
||||
expect(account.reload.custom_attributes).to eq(
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
name: 'CHATWOOT_CLOUD_PLANS',
|
||||
value: [
|
||||
{
|
||||
stripe_customer_id: 'cus_random_number',
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
'name' => 'Hacker',
|
||||
'limits' => {
|
||||
'captain_responses_monthly' => 5,
|
||||
'captain_responses_topup' => 0
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
it 'calls stripe methods to create a customer and updates the account' do
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription)
|
||||
.to receive(:create)
|
||||
.and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
|
||||
expect(Stripe::Subscription)
|
||||
.to have_received(:create)
|
||||
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
||||
|
||||
expect(account.reload.custom_attributes).to eq(
|
||||
{
|
||||
stripe_customer_id: customer.id,
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id)
|
||||
end
|
||||
|
||||
describe 'when checking for existing subscriptions' do
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
|
||||
] }
|
||||
)
|
||||
end
|
||||
|
||||
context 'when account has no stripe_customer_id' do
|
||||
it 'creates a new subscription' do
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create)
|
||||
expect(Stripe::Subscription).to have_received(:create)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has stripe_customer_id' do
|
||||
let(:stripe_customer_id) { 'cus_random_number' }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when account already has an active subscription' do
|
||||
before do
|
||||
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
|
||||
allow(Stripe::Subscription).to receive(:list)
|
||||
.and_return(Stripe::ListObject.construct_from({ data: ['subscription'] }))
|
||||
end
|
||||
|
||||
context 'when customer has active subscriptions' do
|
||||
it 'returns without modifying the account or contacting Stripe' do
|
||||
expect(Stripe::Customer).not_to receive(:create)
|
||||
|
||||
expect { service.perform }.not_to(change { account.reload.custom_attributes })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when v2 billing configuration is missing' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').destroy!
|
||||
end
|
||||
|
||||
it 'raises an informative error' do
|
||||
expect { service.perform }.to raise_error(
|
||||
StandardError,
|
||||
'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account needs to be upgraded to v2 billing' do
|
||||
context 'when stripe customer already exists' do
|
||||
before do
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
end
|
||||
|
||||
it 'does not create a new subscription' do
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
it 'does not create a new customer but updates custom attributes for v2 billing' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Subscription).not_to have_received(:create)
|
||||
expect(Stripe::Subscription).to have_received(:list).with(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
status: 'active',
|
||||
limit: 1
|
||||
}
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => 'cus_existing',
|
||||
'stripe_pricing_plan_id' => hacker_plan_id,
|
||||
'plan_name' => 'Hacker',
|
||||
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
|
||||
'stripe_billing_version' => 2
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when stripe customer does not exist' do
|
||||
before do
|
||||
allow(Stripe::Customer).to receive(:create).and_return(stripe_customer_double)
|
||||
end
|
||||
|
||||
it 'creates a stripe customer and updates the account with v2 billing attributes' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin.email })
|
||||
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => 'cus_new',
|
||||
'stripe_pricing_plan_id' => hacker_plan_id,
|
||||
'plan_name' => 'Hacker',
|
||||
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
|
||||
'stripe_billing_version' => 2
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -317,4 +317,146 @@ describe Enterprise::Billing::HandleStripeEventService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'credit grant handling' do
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new)
|
||||
.with(account: account).and_return(credit_service)
|
||||
end
|
||||
|
||||
context 'when handling monthly credit grant' do
|
||||
it 'adds credits from Stripe' do
|
||||
allow(credit_service).to receive(:sync_monthly_response_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_123',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API (has complete amount structure)
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_123',
|
||||
customer: 'cus_123',
|
||||
metadata: { 'credits' => '2000' },
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 2000)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_123')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_response_credits).with(2000)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling topup credit grant' do
|
||||
it 'adds topup credits' do
|
||||
allow(credit_service).to receive(:sync_monthly_response_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_456',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API (has complete amount structure)
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_456',
|
||||
customer: 'cus_123',
|
||||
metadata: { 'credits' => '500' },
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 500)
|
||||
),
|
||||
expires_at: nil
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_456')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_response_credits).with(500)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling monetary type credit grant' do
|
||||
it 'adds credits from monetary grant' do
|
||||
allow(credit_service).to receive(:add_response_topup_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_monetary',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API with monetary amount
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_monetary',
|
||||
customer: 'cus_123',
|
||||
metadata: { 'credits' => '1000' },
|
||||
amount: OpenStruct.new(
|
||||
type: 'monetary',
|
||||
monetary: OpenStruct.new(
|
||||
currency: 'usd',
|
||||
value: 1000
|
||||
)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_monetary')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:add_response_topup_credits).with(1000)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling credit grant with zero amount' do
|
||||
it 'does not call credit service' do
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_zero',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API with zero amount
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_zero',
|
||||
customer: 'cus_123',
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 0)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_zero')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
# Ensure we don't accidentally call these methods
|
||||
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user