Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913c0b44d4 | ||
|
|
7d2307d343 | ||
|
|
69b818896a | ||
|
|
01d17674e6 | ||
|
|
689525ce85 | ||
|
|
6abecdfd43 | ||
|
|
418d9cce27 | ||
|
|
eba12567e9 | ||
|
|
d230bb68d0 | ||
|
|
7babcfe622 | ||
|
|
c853d07f02 |
@@ -159,7 +159,7 @@ gem 'working_hours'
|
|||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
|
|
||||||
# Subscriptions, Billing
|
# Subscriptions, Billing
|
||||||
gem 'stripe'
|
gem 'stripe', '17.2.0.pre.alpha.2'
|
||||||
|
|
||||||
## - helper gems --##
|
## - helper gems --##
|
||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
|
|||||||
+2
-2
@@ -902,7 +902,7 @@ GEM
|
|||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (8.5.0)
|
stripe (17.2.0.pre.alpha.2)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
@@ -1110,7 +1110,7 @@ DEPENDENCIES
|
|||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
stackprof
|
stackprof
|
||||||
stripe
|
stripe (= 17.2.0.pre.alpha.2)
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
tidewave
|
tidewave
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ module BillingHelper
|
|||||||
# Return false if not plans are configured, so that no checks are enforced
|
# Return false if not plans are configured, so that no checks are enforced
|
||||||
return false if default_plan.blank?
|
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
|
end
|
||||||
|
|
||||||
def conversations_this_month(account)
|
def conversations_this_month(account)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
class BillingPolicy < ApplicationPolicy
|
||||||
|
def credit_grants?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def pricing_plans?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def topup_options?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def topup?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribe?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel_subscription?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_pricing_plan?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,6 +6,17 @@ if resource.custom_attributes.present?
|
|||||||
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
|
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
|
||||||
json.subscription_status resource.custom_attributes['subscription_status']
|
json.subscription_status resource.custom_attributes['subscription_status']
|
||||||
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
|
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
|
||||||
|
json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present?
|
||||||
|
json.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present?
|
||||||
|
json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present?
|
||||||
|
if resource.custom_attributes['pending_stripe_pricing_plan_id'].present?
|
||||||
|
json.pending_stripe_pricing_plan_id resource.custom_attributes['pending_stripe_pricing_plan_id']
|
||||||
|
end
|
||||||
|
if resource.custom_attributes['pending_subscription_quantity'].present?
|
||||||
|
json.pending_subscription_quantity resource.custom_attributes['pending_subscription_quantity']
|
||||||
|
end
|
||||||
|
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.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?
|
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
|
||||||
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
|
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ en:
|
|||||||
invalid_token: Invalid or expired MFA token
|
invalid_token: Invalid or expired MFA token
|
||||||
invalid_credentials: Invalid credentials or verification code
|
invalid_credentials: Invalid credentials or verification code
|
||||||
feature_unavailable: MFA feature is not available. Please configure encryption keys.
|
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:
|
profile:
|
||||||
mfa:
|
mfa:
|
||||||
enabled: MFA enabled successfully
|
enabled: MFA enabled successfully
|
||||||
@@ -435,3 +441,8 @@ en:
|
|||||||
subject: 'Finish setting up %{custom_domain}'
|
subject: 'Finish setting up %{custom_domain}'
|
||||||
ssl_status:
|
ssl_status:
|
||||||
custom_domain_not_configured: 'Custom domain is not configured'
|
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
|
||||||
|
|||||||
@@ -434,6 +434,20 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :v2 do
|
||||||
|
resources :accounts, only: [] do
|
||||||
|
resource :billing, only: [] do
|
||||||
|
get :credit_grants
|
||||||
|
get :pricing_plans
|
||||||
|
get :topup_options
|
||||||
|
post :topup
|
||||||
|
post :subscribe
|
||||||
|
post :cancel_subscription
|
||||||
|
post :change_pricing_plan
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'
|
post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class Enterprise::Api::V1::AccountsController < Api::BaseController
|
class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||||
include BillingHelper
|
include BillingHelper
|
||||||
|
|
||||||
before_action :fetch_account
|
before_action :fetch_account
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
|
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
class Enterprise::Api::V2::BillingController < Api::BaseController
|
||||||
|
before_action :fetch_account
|
||||||
|
before_action -> { check_authorization(:billing) }
|
||||||
|
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
|
||||||
|
raise NotImplementedError, 'Topup functionality not yet implemented'
|
||||||
|
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
|
||||||
|
raise NotImplementedError, 'Cancel subscription functionality not yet implemented'
|
||||||
|
end
|
||||||
|
|
||||||
|
def change_pricing_plan
|
||||||
|
raise NotImplementedError, 'Change pricing plan functionality not yet implemented'
|
||||||
|
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
|
||||||
@@ -6,8 +6,17 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
|||||||
|
|
||||||
# Attempt to verify the signature. If successful, we'll handle the event
|
# Attempt to verify the signature. If successful, we'll handle the event
|
||||||
begin
|
begin
|
||||||
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
|
# Determine which webhook secret to use based on event type
|
||||||
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
|
webhook_secret = determine_webhook_secret(payload)
|
||||||
|
|
||||||
|
event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||||
|
|
||||||
|
# Check if this is a V2 billing event
|
||||||
|
if v2_billing_event?(event.type)
|
||||||
|
::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event)
|
||||||
|
else
|
||||||
|
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
|
||||||
|
end
|
||||||
# If we fail to verify the signature, then something was wrong with the request
|
# If we fail to verify the signature, then something was wrong with the request
|
||||||
rescue JSON::ParserError, Stripe::SignatureVerificationError
|
rescue JSON::ParserError, Stripe::SignatureVerificationError
|
||||||
# Invalid payload
|
# Invalid payload
|
||||||
@@ -18,4 +27,27 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
|||||||
# We've successfully processed the event without blowing up
|
# We've successfully processed the event without blowing up
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def determine_webhook_secret(payload)
|
||||||
|
# Parse the payload to check event type without full verification
|
||||||
|
parsed_payload = JSON.parse(payload)
|
||||||
|
event_type = parsed_payload['type']
|
||||||
|
|
||||||
|
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
|
||||||
|
ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)
|
||||||
|
end
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
module Enterprise::Account::PlanUsageAndLimits
|
module Enterprise::Account::PlanUsageAndLimits
|
||||||
|
# Total credits
|
||||||
CAPTAIN_RESPONSES = 'captain_responses'.freeze
|
CAPTAIN_RESPONSES = 'captain_responses'.freeze
|
||||||
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
|
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
|
||||||
|
|
||||||
|
# Response credits breakdown (monthly + topup)
|
||||||
|
CAPTAIN_RESPONSES_MONTHLY = 'captain_responses_monthly'.freeze
|
||||||
|
CAPTAIN_RESPONSES_TOPUP = 'captain_responses_topup'.freeze
|
||||||
|
|
||||||
|
# Usage tracking
|
||||||
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
|
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
|
||||||
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
|
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
|
||||||
|
|
||||||
@@ -16,8 +23,7 @@ module Enterprise::Account::PlanUsageAndLimits
|
|||||||
end
|
end
|
||||||
|
|
||||||
def increment_response_usage
|
def increment_response_usage
|
||||||
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
custom_attributes[CAPTAIN_RESPONSES_USAGE] = (custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0) + 1
|
||||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
|
|
||||||
save
|
save
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -58,11 +64,12 @@ module Enterprise::Account::PlanUsageAndLimits
|
|||||||
else
|
else
|
||||||
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
consumed = 0 if consumed.negative?
|
consumed = 0 if consumed.negative?
|
||||||
|
|
||||||
{
|
{
|
||||||
total_count: total_count,
|
total_count: total_count,
|
||||||
|
monthly: (self[:limits][CAPTAIN_RESPONSES_MONTHLY].to_i if type == :responses),
|
||||||
|
topup: (self[:limits][CAPTAIN_RESPONSES_TOPUP].to_i if type == :responses),
|
||||||
current_available: (total_count - consumed).clamp(0, total_count),
|
current_available: (total_count - consumed).clamp(0, total_count),
|
||||||
consumed: consumed
|
consumed: consumed
|
||||||
}
|
}
|
||||||
@@ -96,17 +103,12 @@ module Enterprise::Account::PlanUsageAndLimits
|
|||||||
end
|
end
|
||||||
|
|
||||||
def agent_limits
|
def agent_limits
|
||||||
subscribed_quantity = custom_attributes['subscribed_quantity']
|
custom_attributes['subscribed_quantity'] || get_limits(:agents)
|
||||||
subscribed_quantity || get_limits(:agents)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_limits(limit_name)
|
def get_limits(limit_name)
|
||||||
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
|
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
|
||||||
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
|
self[:limits][limit_name.to_s].presence || GlobalConfig.get(config_name)[config_name].presence || ChatwootApp.max_limit
|
||||||
|
|
||||||
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
|
|
||||||
|
|
||||||
ChatwootApp.max_limit
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_limit_keys
|
def validate_limit_keys
|
||||||
@@ -119,7 +121,9 @@ module Enterprise::Account::PlanUsageAndLimits
|
|||||||
'inboxes' => { 'type': 'number' },
|
'inboxes' => { 'type': 'number' },
|
||||||
'agents' => { 'type': 'number' },
|
'agents' => { 'type': 'number' },
|
||||||
'captain_responses' => { 'type': 'number' },
|
'captain_responses' => { 'type': 'number' },
|
||||||
'captain_documents' => { 'type': 'number' }
|
'captain_documents' => { 'type': 'number' },
|
||||||
|
'captain_responses_monthly' => { 'type': 'number' },
|
||||||
|
'captain_responses_topup' => { 'type': 'number' }
|
||||||
},
|
},
|
||||||
'required' => [],
|
'required' => [],
|
||||||
'additionalProperties' => false
|
'additionalProperties' => false
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
module Enterprise::Billing::Concerns::PlanFeatureManager
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
# Plan hierarchy: Hacker (default) -> Startup -> Business -> Enterprise
|
||||||
|
# Each higher tier includes all features from the lower tiers
|
||||||
|
|
||||||
|
# Basic features available starting with the Startup plan
|
||||||
|
STARTUP_PLAN_FEATURES = %w[
|
||||||
|
inbound_emails
|
||||||
|
help_center
|
||||||
|
campaigns
|
||||||
|
team_management
|
||||||
|
channel_twitter
|
||||||
|
channel_facebook
|
||||||
|
channel_email
|
||||||
|
channel_instagram
|
||||||
|
captain_integration
|
||||||
|
advanced_search_indexing
|
||||||
|
advanced_search
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
# Additional features available starting with the Business plan
|
||||||
|
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||||
|
|
||||||
|
# Additional features available only in the Enterprise plan
|
||||||
|
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||||
|
|
||||||
|
def update_plan_features(plan_name)
|
||||||
|
if plan_name.blank? || plan_name == 'Hacker'
|
||||||
|
disable_all_premium_features
|
||||||
|
else
|
||||||
|
enable_features_for_current_plan(plan_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Enable any manually managed features configured in internal_attributes
|
||||||
|
enable_account_manually_managed_features
|
||||||
|
|
||||||
|
account.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable_all_premium_features
|
||||||
|
# Disable all features (for default Hacker plan or during plan changes)
|
||||||
|
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||||
|
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable_features_for_current_plan(plan_name)
|
||||||
|
disable_all_premium_features
|
||||||
|
enable_plan_specific_features(plan_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable_plan_specific_features(plan_name)
|
||||||
|
return if plan_name.blank?
|
||||||
|
|
||||||
|
# Enable features based on plan hierarchy
|
||||||
|
case plan_name
|
||||||
|
when 'Startups'
|
||||||
|
# Startup plan gets the basic features
|
||||||
|
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
when 'Business'
|
||||||
|
# Business plan gets Startup features + Business features
|
||||||
|
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||||
|
when 'Enterprise'
|
||||||
|
# Enterprise plan gets all features
|
||||||
|
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||||
|
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset_captain_usage
|
||||||
|
account.reset_response_usage if account.respond_to?(:reset_response_usage)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def enable_account_manually_managed_features
|
||||||
|
# Get manually managed features from internal attributes using the service
|
||||||
|
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||||
|
features = service.manually_managed_features
|
||||||
|
|
||||||
|
# Enable each feature
|
||||||
|
account.enable_features(*features) if features.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def provision_new_plan(new_pricing_plan_id)
|
||||||
|
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
|
||||||
|
return unless plan_definition
|
||||||
|
|
||||||
|
plan_name = plan_definition[:display_name]
|
||||||
|
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
module Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Stripe client instance with API key
|
||||||
|
def stripe_client
|
||||||
|
@stripe_client ||= Stripe::StripeClient.new(ENV.fetch('STRIPE_SECRET_KEY', nil))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pricing Plan Subscriptions
|
||||||
|
def retrieve_pricing_plan_subscription(subscription_id)
|
||||||
|
stripe_client.v2.billing.pricing_plan_subscriptions.retrieve(subscription_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pricing Plans
|
||||||
|
def retrieve_pricing_plan(pricing_plan_id)
|
||||||
|
stripe_client.v2.billing.pricing_plans.retrieve(pricing_plan_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def retrieve_billing_cadence(cadence_id)
|
||||||
|
stripe_client.v2.billing.cadences.retrieve(cadence_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_billing_intent(params)
|
||||||
|
response = Faraday.post('https://api.stripe.com/v2/billing/intents') do |req|
|
||||||
|
req.headers['Authorization'] = "Bearer #{ENV.fetch('STRIPE_SECRET_KEY', nil)}"
|
||||||
|
req.headers['Stripe-Version'] = default_stripe_version
|
||||||
|
req.headers['Content-Type'] = 'application/json'
|
||||||
|
req.body = params.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reserve_billing_intent(billing_intent_id)
|
||||||
|
stripe_client.v2.billing.intents.reserve(billing_intent_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def commit_billing_intent(billing_intent_id)
|
||||||
|
stripe_client.v2.billing.intents.commit(billing_intent_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checkout Sessions (V1 API but used with V2 plans)
|
||||||
|
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::Billing::CreditGrant.retrieve(grant_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_stripe_version
|
||||||
|
'2025-10-29.preview'
|
||||||
|
end
|
||||||
|
|
||||||
|
def checkout_stripe_version
|
||||||
|
'2025-10-29.preview;checkout_product_catalog_preview=v1'
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_attribute(object, key)
|
||||||
|
return object.public_send(key) if object.respond_to?(key)
|
||||||
|
|
||||||
|
object[key.to_s]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class Enterprise::Billing::CreateStripeCustomerService
|
class Enterprise::Billing::CreateStripeCustomerService
|
||||||
|
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||||
|
|
||||||
pattr_initialize [:account!]
|
pattr_initialize [:account!]
|
||||||
|
|
||||||
DEFAULT_QUANTITY = 2
|
DEFAULT_QUANTITY = 2
|
||||||
@@ -6,22 +8,11 @@ class Enterprise::Billing::CreateStripeCustomerService
|
|||||||
def perform
|
def perform
|
||||||
return if existing_subscription?
|
return if existing_subscription?
|
||||||
|
|
||||||
|
raise_config_error unless v2_configs_present?
|
||||||
|
|
||||||
customer_id = prepare_customer_id
|
customer_id = prepare_customer_id
|
||||||
subscription = Stripe::Subscription.create(
|
update_account_for_v2_billing(customer_id)
|
||||||
{
|
enable_plan_specific_features('Hacker')
|
||||||
customer: customer_id,
|
|
||||||
items: [{ price: price_id, quantity: default_quantity }]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
account.update!(
|
|
||||||
custom_attributes: {
|
|
||||||
stripe_customer_id: customer_id,
|
|
||||||
stripe_price_id: subscription['plan']['id'],
|
|
||||||
stripe_product_id: subscription['plan']['product'],
|
|
||||||
plan_name: default_plan['name'],
|
|
||||||
subscribed_quantity: subscription['quantity']
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -35,22 +26,16 @@ class Enterprise::Billing::CreateStripeCustomerService
|
|||||||
customer_id
|
customer_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_quantity
|
|
||||||
default_plan['default_quantity'] || DEFAULT_QUANTITY
|
|
||||||
end
|
|
||||||
|
|
||||||
def billing_email
|
def billing_email
|
||||||
account.administrators.first.email
|
account.administrators.first.email
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_plan
|
def v2_configs_present?
|
||||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').present?
|
||||||
@default_plan ||= installation_config.value.first
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def price_id
|
def raise_config_error
|
||||||
price_ids = default_plan['price_ids']
|
raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required')
|
||||||
price_ids.first
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def existing_subscription?
|
def existing_subscription?
|
||||||
@@ -66,4 +51,23 @@ class Enterprise::Billing::CreateStripeCustomerService
|
|||||||
)
|
)
|
||||||
subscriptions.data.present?
|
subscriptions.data.present?
|
||||||
end
|
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,
|
||||||
|
stripe_billing_version: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if hacker_plan_config&.value.present?
|
||||||
|
attributes.merge!(
|
||||||
|
stripe_pricing_plan_id: hacker_plan_config.value,
|
||||||
|
plan_name: 'Hacker',
|
||||||
|
subscribed_quantity: DEFAULT_QUANTITY
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
account.update!(custom_attributes: attributes)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,30 +1,9 @@
|
|||||||
class Enterprise::Billing::HandleStripeEventService
|
class Enterprise::Billing::HandleStripeEventService
|
||||||
|
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||||
|
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||||
|
|
||||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||||
|
|
||||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
|
||||||
# Each higher tier includes all features from the lower tiers
|
|
||||||
|
|
||||||
# Basic features available starting with the Startups plan
|
|
||||||
STARTUP_PLAN_FEATURES = %w[
|
|
||||||
inbound_emails
|
|
||||||
help_center
|
|
||||||
campaigns
|
|
||||||
team_management
|
|
||||||
channel_twitter
|
|
||||||
channel_facebook
|
|
||||||
channel_email
|
|
||||||
channel_instagram
|
|
||||||
captain_integration
|
|
||||||
advanced_search_indexing
|
|
||||||
advanced_search
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
# Additional features available starting with the Business plan
|
|
||||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
|
||||||
|
|
||||||
# Additional features available only in the Enterprise plan
|
|
||||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
|
||||||
|
|
||||||
def perform(event:)
|
def perform(event:)
|
||||||
@event = event
|
@event = event
|
||||||
|
|
||||||
@@ -33,6 +12,8 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
process_subscription_updated
|
process_subscription_updated
|
||||||
when 'customer.subscription.deleted'
|
when 'customer.subscription.deleted'
|
||||||
process_subscription_deleted
|
process_subscription_deleted
|
||||||
|
when 'billing.credit_grant.created'
|
||||||
|
process_credit_grant_created
|
||||||
else
|
else
|
||||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||||
end
|
end
|
||||||
@@ -47,7 +28,8 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
return if plan.blank? || account.blank?
|
return if plan.blank? || account.blank?
|
||||||
|
|
||||||
update_account_attributes(subscription, plan)
|
update_account_attributes(subscription, plan)
|
||||||
update_plan_features
|
plan_name = account.custom_attributes['plan_name']
|
||||||
|
update_plan_features(plan_name)
|
||||||
reset_captain_usage
|
reset_captain_usage
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -73,65 +55,22 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_plan_features
|
|
||||||
if default_plan?
|
|
||||||
disable_all_premium_features
|
|
||||||
else
|
|
||||||
enable_features_for_current_plan
|
|
||||||
end
|
|
||||||
|
|
||||||
# Enable any manually managed features configured in internal_attributes
|
|
||||||
enable_account_manually_managed_features
|
|
||||||
|
|
||||||
account.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
def disable_all_premium_features
|
|
||||||
# Disable all features (for default Hacker plan)
|
|
||||||
account.disable_features(*STARTUP_PLAN_FEATURES)
|
|
||||||
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
|
||||||
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
|
||||||
end
|
|
||||||
|
|
||||||
def enable_features_for_current_plan
|
|
||||||
# First disable all premium features to handle downgrades
|
|
||||||
disable_all_premium_features
|
|
||||||
|
|
||||||
# Then enable features based on the current plan
|
|
||||||
enable_plan_specific_features
|
|
||||||
end
|
|
||||||
|
|
||||||
def reset_captain_usage
|
|
||||||
account.reset_response_usage
|
|
||||||
end
|
|
||||||
|
|
||||||
def enable_plan_specific_features
|
|
||||||
plan_name = account.custom_attributes['plan_name']
|
|
||||||
return if plan_name.blank?
|
|
||||||
|
|
||||||
# Enable features based on plan hierarchy
|
|
||||||
case plan_name
|
|
||||||
when 'Startups'
|
|
||||||
# Startups plan gets the basic features
|
|
||||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
|
||||||
when 'Business'
|
|
||||||
# Business plan gets Startups features + Business features
|
|
||||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
|
||||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
|
||||||
when 'Enterprise'
|
|
||||||
# Enterprise plan gets all features
|
|
||||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
|
||||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
|
||||||
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscription
|
def subscription
|
||||||
@subscription ||= @event.data.object
|
@subscription ||= @event.data.object
|
||||||
end
|
end
|
||||||
|
|
||||||
def account
|
def account
|
||||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
@account ||= begin
|
||||||
|
customer_id = if @event.type.start_with?('billing.credit_grant')
|
||||||
|
# Credit grant events have customer directly on the object
|
||||||
|
@event.data.object.respond_to?(:customer) ? @event.data.object.customer : @event.data.object['customer']
|
||||||
|
else
|
||||||
|
# Subscription events have customer on subscription
|
||||||
|
subscription.customer
|
||||||
|
end
|
||||||
|
|
||||||
|
Account.where("custom_attributes->>'stripe_customer_id' = ?", customer_id).first
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_plan(plan_id)
|
def find_plan(plan_id)
|
||||||
@@ -139,18 +78,60 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_plan?
|
def process_credit_grant_created
|
||||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
grant_id = extract_credit_grant_id(@event.data.object)
|
||||||
default_plan = cloud_plans.first || {}
|
return if grant_id.blank?
|
||||||
account.custom_attributes['plan_name'] == default_plan['name']
|
|
||||||
|
# Retrieve the full credit grant object from Stripe API
|
||||||
|
grant = retrieve_credit_grant(grant_id)
|
||||||
|
return if grant.blank?
|
||||||
|
|
||||||
|
amount = extract_credit_amount(grant)
|
||||||
|
return if amount.zero?
|
||||||
|
|
||||||
|
grant_type = extract_grant_type(grant)
|
||||||
|
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||||
|
if grant_type == 'monetary'
|
||||||
|
service.add_response_topup_credits(amount)
|
||||||
|
else
|
||||||
|
service.sync_monthly_response_credits(amount)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_account_manually_managed_features
|
def extract_credit_grant_id(grant_object)
|
||||||
# Get manually managed features from internal attributes using the service
|
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
|
||||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
end
|
||||||
features = service.manually_managed_features
|
|
||||||
|
|
||||||
# Enable each feature
|
def extract_grant_type(grant)
|
||||||
account.enable_features(*features) if features.present?
|
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)
|
||||||
|
if metadata
|
||||||
|
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?
|
||||||
|
|
||||||
|
custom_pricing_unit = extract_attribute(amount, :custom_pricing_unit)
|
||||||
|
return 0 if custom_pricing_unit.blank?
|
||||||
|
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
class Enterprise::Billing::V2::BaseService
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account:)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def stripe_client
|
||||||
|
@stripe_client ||= Stripe::StripeClient.new(
|
||||||
|
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def response_monthly_credits
|
||||||
|
account.limits&.[]('captain_responses_monthly').to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def response_topup_credits
|
||||||
|
account.limits&.[]('captain_responses_topup').to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def response_usage
|
||||||
|
account.custom_attributes&.[]('captain_responses_usage').to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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 if monthly.nil? && topup.nil?
|
||||||
|
|
||||||
|
new_monthly = monthly || response_monthly_credits
|
||||||
|
new_topup = topup || response_topup_credits
|
||||||
|
total_credits = new_monthly + new_topup
|
||||||
|
limits = {
|
||||||
|
'captain_responses_monthly' => new_monthly,
|
||||||
|
'captain_responses_topup' => new_topup,
|
||||||
|
'captain_responses' => total_credits
|
||||||
|
}
|
||||||
|
update_limits(limits)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_limits(updates)
|
||||||
|
return if updates.blank?
|
||||||
|
|
||||||
|
account.update!(limits: (account.limits || {}).merge(updates.transform_keys(&:to_s)))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_custom_attributes(updates)
|
||||||
|
return if updates.blank?
|
||||||
|
|
||||||
|
account.update!(custom_attributes: (account.custom_attributes || {}).merge(updates.transform_keys(&:to_s)))
|
||||||
|
end
|
||||||
|
|
||||||
|
def custom_attribute(key)
|
||||||
|
account.custom_attributes&.[](key.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_locked_account(&)
|
||||||
|
account.with_lock(&)
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService
|
||||||
|
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||||
|
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||||
|
|
||||||
|
def create_subscription_checkout(pricing_plan_id:, quantity: 1)
|
||||||
|
@pricing_plan_id = pricing_plan_id
|
||||||
|
@quantity = quantity.to_i.positive? ? quantity.to_i : 1
|
||||||
|
|
||||||
|
base_url = ENV.fetch('FRONTEND_URL')
|
||||||
|
@success_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing?session_id={CHECKOUT_SESSION_ID}"
|
||||||
|
@cancel_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing"
|
||||||
|
|
||||||
|
validate_params
|
||||||
|
store_pending_subscription_quantity
|
||||||
|
session = create_checkout_session(checkout_session_params)
|
||||||
|
session.url
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_params
|
||||||
|
raise StandardError, I18n.t('errors.enterprise.billing.stripe_customer_required') if stripe_customer_id.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def store_pending_subscription_quantity
|
||||||
|
# Store quantity in custom_attributes for webhook to use
|
||||||
|
# This is more reliable than extracting from subscription component_values
|
||||||
|
update_custom_attributes({
|
||||||
|
'pending_subscription_quantity' => @quantity,
|
||||||
|
'pending_subscription_pricing_plan' => @pricing_plan_id
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def checkout_session_params
|
||||||
|
{
|
||||||
|
customer: stripe_customer_id,
|
||||||
|
checkout_items: build_checkout_items,
|
||||||
|
automatic_tax: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
customer_update: {
|
||||||
|
address: 'auto',
|
||||||
|
shipping: 'auto'
|
||||||
|
},
|
||||||
|
success_url: @success_url,
|
||||||
|
cancel_url: @cancel_url,
|
||||||
|
metadata: session_metadata
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_checkout_items
|
||||||
|
lookup_key = extract_license_lookup_key
|
||||||
|
raise StandardError, I18n.t('errors.enterprise.billing.lookup_key_not_found', pricing_plan_id: @pricing_plan_id) unless lookup_key
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'pricing_plan_subscription_item',
|
||||||
|
pricing_plan_subscription_item: {
|
||||||
|
pricing_plan: @pricing_plan_id,
|
||||||
|
component_configurations: {
|
||||||
|
lookup_key => {
|
||||||
|
type: 'license_fee_component',
|
||||||
|
license_fee_component: {
|
||||||
|
quantity: @quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_license_lookup_key
|
||||||
|
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_metadata
|
||||||
|
{
|
||||||
|
account_id: account.id,
|
||||||
|
pricing_plan_id: @pricing_plan_id,
|
||||||
|
quantity: @quantity,
|
||||||
|
billing_version: 'v2'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
|
||||||
|
# Sync monthly response credits (resets on billing cycle with topup preservation)
|
||||||
|
def sync_monthly_response_credits(amount)
|
||||||
|
with_locked_account do
|
||||||
|
# Preserve topup credits but cap at remaining balance
|
||||||
|
preserved_topup = preserve_topup_on_reset(
|
||||||
|
current_topup: response_topup_credits,
|
||||||
|
new_monthly: amount,
|
||||||
|
current_usage: response_usage
|
||||||
|
)
|
||||||
|
update_response_credits(monthly: amount, topup: preserved_topup)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add topup credits for responses
|
||||||
|
def add_response_topup_credits(amount)
|
||||||
|
with_locked_account do
|
||||||
|
new_topup = response_topup_credits + amount
|
||||||
|
update_response_credits(topup: new_topup)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_credit_grants
|
||||||
|
return [] if stripe_customer_id.blank?
|
||||||
|
|
||||||
|
response = Stripe::Billing::CreditGrant.list(
|
||||||
|
{ customer: stripe_customer_id, limit: 100 }
|
||||||
|
)
|
||||||
|
|
||||||
|
grants = response.data.map do |grant|
|
||||||
|
transform_credit_grant(grant)
|
||||||
|
end
|
||||||
|
grants.reject { |grant| grant[:credits].zero? }
|
||||||
|
rescue Stripe::StripeError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Preserve topup credits on monthly reset, capped at remaining balance
|
||||||
|
# Formula: min(current_topup, max(0, (new_monthly + current_topup) - current_usage))
|
||||||
|
def preserve_topup_on_reset(current_topup:, new_monthly:, current_usage:)
|
||||||
|
# Calculate remaining balance after usage
|
||||||
|
total_after_sync = new_monthly + current_topup
|
||||||
|
remaining_balance = [total_after_sync - current_usage, 0].max
|
||||||
|
|
||||||
|
# Cap topup at remaining balance to avoid over-crediting
|
||||||
|
[current_topup, remaining_balance].min
|
||||||
|
end
|
||||||
|
|
||||||
|
def transform_credit_grant(grant)
|
||||||
|
category = grant_attribute(grant, :category)
|
||||||
|
metadata = grant_attribute(grant, :metadata) || {}
|
||||||
|
|
||||||
|
{
|
||||||
|
id: grant_attribute(grant, :id),
|
||||||
|
name: grant_attribute(grant, :name),
|
||||||
|
credits: calculate_grant_credits(category, metadata),
|
||||||
|
category: category,
|
||||||
|
source: metadata['source'] || category,
|
||||||
|
effective_at: parse_timestamp(grant_attribute(grant, :effective_at)),
|
||||||
|
expires_at: parse_timestamp(grant_attribute(grant, :expires_at)),
|
||||||
|
voided_at: parse_timestamp(grant_attribute(grant, :voided_at)),
|
||||||
|
created_at: parse_timestamp(grant_attribute(grant, :created))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def grant_attribute(grant, key)
|
||||||
|
grant[key] || grant.public_send(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_grant_credits(category, metadata)
|
||||||
|
return metadata['credits'].to_i if category == 'paid' && metadata['credits']
|
||||||
|
|
||||||
|
0
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_timestamp(timestamp)
|
||||||
|
return nil unless timestamp
|
||||||
|
|
||||||
|
Time.zone.at(timestamp)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
module Enterprise::Billing::V2::PlanCatalog
|
||||||
|
DEFAULT_CURRENCY = 'usd'.freeze
|
||||||
|
CREDIT_UNIT = 'Credits'.freeze
|
||||||
|
|
||||||
|
PLAN_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
key: :free,
|
||||||
|
display_name: 'Hacker',
|
||||||
|
base_fee: 0.0,
|
||||||
|
monthly_credits: 0,
|
||||||
|
config_key: 'STRIPE_HACKER_PLAN_ID',
|
||||||
|
licensed_item_lookup_key: 'chatwoot_hacker_license_fee_v2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: :startup,
|
||||||
|
display_name: 'Startups',
|
||||||
|
base_fee: 19.0,
|
||||||
|
monthly_credits: 300,
|
||||||
|
config_key: 'STRIPE_STARTUP_PLAN_ID',
|
||||||
|
licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: :business,
|
||||||
|
display_name: 'Business',
|
||||||
|
base_fee: 39.0,
|
||||||
|
monthly_credits: 500,
|
||||||
|
config_key: 'STRIPE_BUSINESS_PLAN_ID',
|
||||||
|
licensed_item_lookup_key: 'chatwoot_business_license_fee_v2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: :enterprise,
|
||||||
|
display_name: 'Enterprise',
|
||||||
|
base_fee: 99.0,
|
||||||
|
monthly_credits: 800,
|
||||||
|
config_key: 'STRIPE_ENTERPRISE_PLAN_ID',
|
||||||
|
licensed_item_lookup_key: 'chatwoot_enterprise_license_fee_v2'
|
||||||
|
}
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def plans
|
||||||
|
PLAN_DEFINITIONS.map do |definition|
|
||||||
|
plan_id = plan_id_for(definition)
|
||||||
|
build_plan(definition, plan_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def definition_for(plan_id)
|
||||||
|
PLAN_DEFINITIONS.each do |definition|
|
||||||
|
return definition if plan_id_for(definition) == plan_id
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def plan_id_for(definition)
|
||||||
|
InstallationConfig.find_by(name: definition[:config_key])&.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def lookup_key_for_plan(plan_id)
|
||||||
|
# Returns the licensed_item_lookup_key for checkout sessions
|
||||||
|
definition = definition_for(plan_id)
|
||||||
|
definition&.dig(:licensed_item_lookup_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_plan(definition, plan_id)
|
||||||
|
{
|
||||||
|
id: plan_id,
|
||||||
|
display_name: definition[:display_name],
|
||||||
|
currency: DEFAULT_CURRENCY,
|
||||||
|
tax_behavior: 'exclusive',
|
||||||
|
components: build_components(definition)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_components(definition)
|
||||||
|
components = [service_action_component(definition), rate_card_component(definition)]
|
||||||
|
components << license_fee_component(definition) if definition[:base_fee]&.positive?
|
||||||
|
components
|
||||||
|
end
|
||||||
|
|
||||||
|
def service_action_component(definition)
|
||||||
|
{
|
||||||
|
type: 'service_action',
|
||||||
|
name: 'Monthly Credits',
|
||||||
|
credit_amount: definition[:monthly_credits],
|
||||||
|
credit_unit: CREDIT_UNIT
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def rate_card_component(_definition)
|
||||||
|
{
|
||||||
|
type: 'rate_card',
|
||||||
|
name: 'Overage Rate',
|
||||||
|
rate_unit: CREDIT_UNIT,
|
||||||
|
meter_id: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def license_fee_component(definition)
|
||||||
|
{
|
||||||
|
type: 'license_fee',
|
||||||
|
name: 'Base Fee',
|
||||||
|
unit_amount: definition[:base_fee].round(2)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService
|
||||||
|
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||||
|
include Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||||
|
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?
|
||||||
|
|
||||||
|
# Reset usage for the new billing cycle
|
||||||
|
reset_captain_usage
|
||||||
|
|
||||||
|
{ pricing_plan_id: pricing_plan_id, quantity: quantity }
|
||||||
|
end
|
||||||
|
|
||||||
|
def servicing_status(subscription_plan)
|
||||||
|
extract_attribute(subscription_plan, :servicing_status)
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
attributes = {
|
||||||
|
'plan_name': 'Hacker',
|
||||||
|
'stripe_pricing_plan_id': pricing_plan_id,
|
||||||
|
'subscribed_quantity': 2,
|
||||||
|
'stripe_subscription_id': nil,
|
||||||
|
'billing_cadence': nil,
|
||||||
|
'subscription_status': 'canceled'
|
||||||
|
}
|
||||||
|
update_custom_attributes(attributes)
|
||||||
|
|
||||||
|
# Sync credits for Hacker plan (0 credits)
|
||||||
|
Enterprise::Billing::V2::CreditManagementService
|
||||||
|
.new(account: account)
|
||||||
|
.sync_monthly_response_credits(0)
|
||||||
|
|
||||||
|
# Disable all premium features and save
|
||||||
|
disable_all_premium_features
|
||||||
|
account.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_pricing_plan_id(subscription)
|
||||||
|
extract_attribute(subscription, :pricing_plan)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_billing_cadence(subscription)
|
||||||
|
extract_attribute(subscription, :billing_cadence)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_subscription_quantity(_subscription)
|
||||||
|
# Get quantity from account custom_attributes (set during checkout)
|
||||||
|
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
|
||||||
|
|
||||||
|
def update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
|
||||||
|
Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \
|
||||||
|
"pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}"
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
'stripe_subscription_id' => subscription_id,
|
||||||
|
'subscribed_quantity' => quantity,
|
||||||
|
'subscription_status' => 'active',
|
||||||
|
'pending_subscription_quantity' => nil,
|
||||||
|
'pending_subscription_pricing_plan' => nil,
|
||||||
|
'billing_cadence' => billing_cadence,
|
||||||
|
'next_billing_date' => nil,
|
||||||
|
'pending_stripe_pricing_plan_id' => nil,
|
||||||
|
'stripe_billing_version' => 2
|
||||||
|
}
|
||||||
|
attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present?
|
||||||
|
|
||||||
|
# Add plan name from catalog
|
||||||
|
if pricing_plan_id.present?
|
||||||
|
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
|
||||||
|
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
|
||||||
|
end
|
||||||
|
|
||||||
|
update_custom_attributes(attributes)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
module Enterprise::Billing::V2::TopupCatalog
|
||||||
|
DEFAULT_TOPUPS = [
|
||||||
|
{ credits: 1000, amount: 20.0 },
|
||||||
|
{ credits: 2000, amount: 40.0 },
|
||||||
|
{ credits: 3000, amount: 60.0 },
|
||||||
|
{ credits: 4000, amount: 80.0 }
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def options
|
||||||
|
custom_options = InstallationConfig.find_by(name: 'STRIPE_TOPUP_OPTIONS')&.value
|
||||||
|
parsed = parse_options(custom_options)
|
||||||
|
(parsed.presence || DEFAULT_TOPUPS).sort_by { |opt| opt[:credits] }.map do |option|
|
||||||
|
{
|
||||||
|
credits: option[:credits],
|
||||||
|
amount: option[:amount],
|
||||||
|
currency: option[:currency] || 'usd'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_options(raw)
|
||||||
|
return [] if raw.blank?
|
||||||
|
|
||||||
|
JSON.parse(raw, symbolize_names: true)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_option(credits)
|
||||||
|
options.find { |option| option[:credits].to_i == credits.to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
class Enterprise::Billing::V2::WebhookHandlerService
|
||||||
|
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||||
|
|
||||||
|
def perform(event:)
|
||||||
|
@event = event
|
||||||
|
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'
|
||||||
|
Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}"
|
||||||
|
handle_subscription_servicing_activated(@event.related_object.id)
|
||||||
|
when 'v2.billing.cadence.billed'
|
||||||
|
Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}"
|
||||||
|
refresh_account_subscription_details(@event.related_object.id)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def account
|
||||||
|
@account ||= begin
|
||||||
|
related_object = @event.related_object
|
||||||
|
subscription_id = related_object.id
|
||||||
|
|
||||||
|
customer_id = fetch_customer_id_from_subscription(subscription_id)
|
||||||
|
found_account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) if customer_id.present?
|
||||||
|
|
||||||
|
Rails.logger.warn "Could not find account for subscription #{subscription_id}" if found_account.blank?
|
||||||
|
|
||||||
|
found_account
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_customer_id_from_subscription(subscription_id)
|
||||||
|
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||||
|
return nil unless subscription&.billing_cadence
|
||||||
|
|
||||||
|
cadence = retrieve_billing_cadence(subscription.billing_cadence)
|
||||||
|
cadence.payer&.customer
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_subscription_servicing_activated(subscription_id)
|
||||||
|
Enterprise::Billing::V2::SubscriptionProvisioningService
|
||||||
|
.new(account: account)
|
||||||
|
.provision(subscription_id: subscription_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_account_subscription_details(_cadence_id)
|
||||||
|
Enterprise::Billing::V2::SubscriptionProvisioningService
|
||||||
|
.new(account: account)
|
||||||
|
.refresh
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -205,8 +205,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
|||||||
},
|
},
|
||||||
'conversation' => {},
|
'conversation' => {},
|
||||||
'captain' => {
|
'captain' => {
|
||||||
'documents' => { '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,
|
||||||
'responses' => { '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' => {}
|
'non_web_inboxes' => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,34 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
||||||
describe 'POST /enterprise/webhooks/stripe' 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
|
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
|
||||||
handle_stripe = double
|
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(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
|
||||||
allow(handle_stripe).to receive(:perform)
|
allow(handle_stripe).to receive(:perform)
|
||||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
|
||||||
expect(handle_stripe).to have_received(:perform).with(event: params)
|
expect(handle_stripe).to have_received(:perform).with(event: event_object)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns a bad request if the headers are missing' do
|
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)
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns a bad request if the headers are invalid' do
|
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)
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+78
-118
@@ -1,139 +1,99 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
describe Enterprise::Billing::CreateStripeCustomerService do
|
describe Enterprise::Billing::CreateStripeCustomerService do
|
||||||
subject(:create_stripe_customer_service) { described_class }
|
subject(:service) { described_class.new(account: account) }
|
||||||
|
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let!(:admin1) { create(:user, account: account, role: :administrator) }
|
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
let(:admin2) { create(:user, account: account, role: :administrator) }
|
let(:stripe_customer_double) { instance_double(Stripe::Customer, id: 'cus_new') }
|
||||||
let(:subscriptions_list) { double }
|
let(:subscriptions_list) { Stripe::ListObject.construct_from({ data: [] }) }
|
||||||
|
let(:hacker_plan_id) { 'price_hacker_random' }
|
||||||
|
|
||||||
describe '#perform' do
|
before do
|
||||||
before do
|
create(
|
||||||
create(
|
:installation_config,
|
||||||
:installation_config,
|
name: 'CHATWOOT_CLOUD_PLANS',
|
||||||
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
|
value: [
|
||||||
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
|
|
||||||
] }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not call stripe methods if customer id is present' do
|
|
||||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
|
|
||||||
allow(subscriptions_list).to receive(:data).and_return([])
|
|
||||||
allow(Stripe::Customer).to receive(:create)
|
|
||||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
|
||||||
allow(Stripe::Subscription).to receive(:create)
|
|
||||||
.and_return(
|
|
||||||
{
|
|
||||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
|
||||||
quantity: 2
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
|
|
||||||
create_stripe_customer_service.new(account: account).perform
|
|
||||||
|
|
||||||
expect(Stripe::Customer).not_to have_received(:create)
|
|
||||||
expect(Stripe::Subscription)
|
|
||||||
.to have_received(:create)
|
|
||||||
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
|
||||||
|
|
||||||
expect(account.reload.custom_attributes).to eq(
|
|
||||||
{
|
{
|
||||||
stripe_customer_id: 'cus_random_number',
|
'name' => 'Hacker',
|
||||||
stripe_price_id: 'price_random_number',
|
'limits' => {
|
||||||
stripe_product_id: 'prod_random_number',
|
'captain_responses_monthly' => 5,
|
||||||
subscribed_quantity: 2,
|
'captain_responses_topup' => 0
|
||||||
plan_name: 'A Plan Name'
|
}
|
||||||
}.with_indifferent_access
|
}
|
||||||
)
|
]
|
||||||
end
|
)
|
||||||
|
|
||||||
it 'calls stripe methods to create a customer and updates the account' do
|
create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id)
|
||||||
customer = double
|
|
||||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
|
||||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
|
||||||
allow(Stripe::Subscription)
|
|
||||||
.to receive(:create)
|
|
||||||
.and_return(
|
|
||||||
{
|
|
||||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
|
||||||
quantity: 2
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
|
|
||||||
create_stripe_customer_service.new(account: account).perform
|
|
||||||
|
|
||||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
|
|
||||||
expect(Stripe::Subscription)
|
|
||||||
.to have_received(:create)
|
|
||||||
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
|
||||||
|
|
||||||
expect(account.reload.custom_attributes).to eq(
|
|
||||||
{
|
|
||||||
stripe_customer_id: customer.id,
|
|
||||||
stripe_price_id: 'price_random_number',
|
|
||||||
stripe_product_id: 'prod_random_number',
|
|
||||||
subscribed_quantity: 2,
|
|
||||||
plan_name: 'A Plan Name'
|
|
||||||
}.with_indifferent_access
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'when checking for existing subscriptions' do
|
describe '#perform' do
|
||||||
before do
|
context 'when account already has an active subscription' 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' }
|
|
||||||
|
|
||||||
before 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
|
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
|
before do
|
||||||
|
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
|
||||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||||
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
|
allow(Stripe::Customer).to receive(:create)
|
||||||
allow(Stripe::Subscription).to receive(:create)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create a new subscription' do
|
it 'does not create a new customer but updates custom attributes for v2 billing' do
|
||||||
create_stripe_customer_service.new(account: account).perform
|
service.perform
|
||||||
|
|
||||||
expect(Stripe::Subscription).not_to have_received(:create)
|
expect(Stripe::Customer).not_to have_received(:create)
|
||||||
expect(Stripe::Subscription).to have_received(:list).with(
|
|
||||||
{
|
expect(account.reload.custom_attributes).to include(
|
||||||
customer: stripe_customer_id,
|
'stripe_customer_id' => 'cus_existing',
|
||||||
status: 'active',
|
'stripe_pricing_plan_id' => hacker_plan_id,
|
||||||
limit: 1
|
'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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -317,4 +317,146 @@ describe Enterprise::Billing::HandleStripeEventService do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user