self review backend changes
This commit is contained in:
@@ -35,6 +35,7 @@ class DashboardController < ActionController::Base
|
||||
'HCAPTCHA_SITE_KEY',
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV',
|
||||
'INSTALLATION_PRICING_PLAN'
|
||||
).merge(app_config)
|
||||
end
|
||||
@@ -70,7 +71,6 @@ class DashboardController < ActionController::Base
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
DEPLOYMENT_ENV: GlobalConfigService.load('DEPLOYMENT_ENV', 'self-hosted'),
|
||||
GIT_SHA: GIT_HASH
|
||||
}
|
||||
end
|
||||
|
||||
@@ -108,7 +108,6 @@ class Account < ApplicationRecord
|
||||
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_create :initialize_v2_billing
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
@@ -166,16 +165,6 @@ class Account < ApplicationRecord
|
||||
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
|
||||
end
|
||||
|
||||
def initialize_v2_billing
|
||||
# Initialize V2 billing for Chatwoot Cloud accounts
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
# Set stripe_billing_version to 2 for all new accounts
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
update_columns(custom_attributes: (custom_attributes || {}).merge('stripe_billing_version' => 2))
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
@@ -47,10 +47,6 @@ class AccountPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def v2_buy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def v2_subscribe?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -16,7 +16,6 @@ if resource.custom_attributes.present?
|
||||
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
|
||||
end
|
||||
# V2 Billing attributes
|
||||
json.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present?
|
||||
json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present?
|
||||
json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present?
|
||||
json.stripe_cadence_id resource.custom_attributes['stripe_cadence_id'] if resource.custom_attributes['stripe_cadence_id'].present?
|
||||
|
||||
@@ -3,6 +3,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
before_action :fetch_account
|
||||
before_action :check_authorization
|
||||
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
|
||||
before_action :validate_topup_amount, only: [:v2_topup]
|
||||
|
||||
def subscription
|
||||
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
|
||||
@@ -72,7 +73,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
def v2_pricing_plans
|
||||
plans = Enterprise::Billing::V2::PlanCatalog.plans
|
||||
|
||||
render json: { pricing_plans: plans }
|
||||
end
|
||||
|
||||
@@ -82,11 +82,8 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def v2_topup
|
||||
amount = params[:credits].to_i
|
||||
return render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity if amount <= 0
|
||||
|
||||
service = Enterprise::Billing::V2::TopupService.new(account: @account)
|
||||
result = service.create_topup(credits: amount)
|
||||
result = service.create_topup(credits: params[:credits].to_i)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, message: result[:message] }
|
||||
@@ -114,12 +111,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
result = service.cancel_subscription
|
||||
|
||||
if result[:success]
|
||||
render json: {
|
||||
success: true,
|
||||
message: result[:message],
|
||||
cancel_at_period_end: result[:cancel_at_period_end],
|
||||
period_end: result[:period_end]
|
||||
}
|
||||
render json: result
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -199,6 +191,13 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
quantity.positive? ? quantity : 1
|
||||
end
|
||||
|
||||
def validate_topup_amount
|
||||
amount = params[:credits].to_i
|
||||
return if amount.positive?
|
||||
|
||||
render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def pundit_user
|
||||
{
|
||||
user: current_user,
|
||||
|
||||
@@ -13,7 +13,7 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
|
||||
# Check if this is a V2 billing event
|
||||
if v2_billing_event?(event)
|
||||
handle_v2_event(event)
|
||||
::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event)
|
||||
else
|
||||
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
|
||||
end
|
||||
@@ -35,62 +35,14 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
parsed_payload = JSON.parse(payload)
|
||||
event_type = parsed_payload['type']
|
||||
|
||||
# Use V2 webhook secret for V2 events, V1 secret for everything else
|
||||
if event_type&.start_with?('v2.')
|
||||
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)
|
||||
event.type.start_with?('v2.')
|
||||
end
|
||||
|
||||
def handle_v2_event(event)
|
||||
account = find_account_for_v2_event(event)
|
||||
return unless account && account_v2_enabled?(account)
|
||||
|
||||
service = ::Enterprise::Billing::V2::WebhookHandlerService.new(account: account)
|
||||
service.process(event)
|
||||
end
|
||||
|
||||
def find_account_for_v2_event(event)
|
||||
related_object = event.related_object
|
||||
subscription_id = related_object.id
|
||||
|
||||
customer_id = fetch_customer_id_from_subscription(subscription_id)
|
||||
if customer_id.present?
|
||||
account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id)
|
||||
return account if account
|
||||
end
|
||||
|
||||
Rails.logger.warn "Could not find account for subscription #{subscription_id}"
|
||||
nil
|
||||
end
|
||||
|
||||
def fetch_customer_id_from_subscription(subscription_id)
|
||||
# Step 1: Fetch subscription
|
||||
subscription = StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
|
||||
{},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
return nil unless subscription&.billing_cadence
|
||||
|
||||
# Step 2: Fetch billing cadence
|
||||
cadence = StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/cadences/#{subscription.billing_cadence}",
|
||||
{},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
# Step 3: Extract customer from payer
|
||||
cadence.payer&.customer
|
||||
end
|
||||
|
||||
def account_v2_enabled?(_account)
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
def v2_billing_event?(event_type)
|
||||
event_type.start_with?('v2.')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,6 +114,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def handle_error(error)
|
||||
log_error(error)
|
||||
refund_credit
|
||||
process_action('handoff')
|
||||
true
|
||||
end
|
||||
@@ -125,4 +126,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def captain_v2_enabled?
|
||||
return account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def refund_credit
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(conversation: @conversation)
|
||||
credit_service.check_and_use_credits(
|
||||
feature: 'ai_captain_conversation',
|
||||
amount: -1, # Negative to refund
|
||||
metadata: {
|
||||
'assistant_id' => @assistant.id,
|
||||
'conversation_id' => @conversation.id,
|
||||
'inbox_id' => @inbox.id,
|
||||
'refund' => true
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,6 @@ class Enterprise::CreateStripeCustomerJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account)
|
||||
# Use V1 service - creates customer and stores customer_id
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,6 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
|
||||
unless credit_result[:success]
|
||||
error_message = credit_result[:message] || 'Insufficient credits'
|
||||
Rails.logger.warn("#{self.class.name} Credit check failed for account #{@account.id}: #{error_message}")
|
||||
return { error: error_message, error_code: 402 }
|
||||
end
|
||||
|
||||
|
||||
@@ -82,14 +82,10 @@ module Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
def enable_account_manually_managed_features
|
||||
# Get manually managed features from internal attributes using the service
|
||||
return unless defined?(Internal::Accounts::InternalAttributesService)
|
||||
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
|
||||
# Enable each feature
|
||||
account.enable_features(*features) if features.present?
|
||||
rescue StandardError
|
||||
# Silently handle errors - account will continue with plan-specific features
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
class Enterprise::Billing::CreateSessionService
|
||||
PORTAL_CONFIGURATION_ID = 'bpc_1SI88PF3O6TPVU2azyI0ek2W'.freeze
|
||||
|
||||
def create_session(customer_id, return_url = ENV.fetch('FRONTEND_URL'))
|
||||
Stripe::BillingPortal::Session.create(
|
||||
{
|
||||
customer: customer_id,
|
||||
return_url: return_url,
|
||||
configuration: PORTAL_CONFIGURATION_ID
|
||||
return_url: return_url
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1,33 +1,69 @@
|
||||
class Enterprise::Billing::CreateStripeCustomerService
|
||||
pattr_initialize [:account!]
|
||||
|
||||
def perform
|
||||
return if customer_exists?
|
||||
DEFAULT_QUANTITY = 2
|
||||
|
||||
customer_id = create_customer
|
||||
save_customer_id(customer_id)
|
||||
def perform
|
||||
return if existing_subscription?
|
||||
|
||||
customer_id = prepare_customer_id
|
||||
subscription = Stripe::Subscription.create(
|
||||
{
|
||||
customer: customer_id,
|
||||
items: [{ price: price_id, quantity: default_quantity }]
|
||||
}
|
||||
)
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
stripe_customer_id: customer_id,
|
||||
stripe_price_id: subscription['plan']['id'],
|
||||
stripe_product_id: subscription['plan']['product'],
|
||||
plan_name: default_plan['name'],
|
||||
subscribed_quantity: subscription['quantity']
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def customer_exists?
|
||||
account.custom_attributes['stripe_customer_id'].present?
|
||||
def prepare_customer_id
|
||||
customer_id = account.custom_attributes['stripe_customer_id']
|
||||
if customer_id.blank?
|
||||
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
|
||||
customer_id = customer.id
|
||||
end
|
||||
customer_id
|
||||
end
|
||||
|
||||
def create_customer
|
||||
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
|
||||
customer.id
|
||||
end
|
||||
|
||||
def save_customer_id(customer_id)
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
stripe_customer_id: customer_id
|
||||
}
|
||||
)
|
||||
def default_quantity
|
||||
default_plan['default_quantity'] || DEFAULT_QUANTITY
|
||||
end
|
||||
|
||||
def billing_email
|
||||
account.administrators.first.email
|
||||
end
|
||||
|
||||
def default_plan
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
@default_plan ||= installation_config.value.first
|
||||
end
|
||||
|
||||
def price_id
|
||||
price_ids = default_plan['price_ids']
|
||||
price_ids.first
|
||||
end
|
||||
|
||||
def existing_subscription?
|
||||
stripe_customer_id = account.custom_attributes['stripe_customer_id']
|
||||
return false if stripe_customer_id.blank?
|
||||
|
||||
subscriptions = Stripe::Subscription.list(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
status: 'active',
|
||||
limit: 1
|
||||
}
|
||||
)
|
||||
subscriptions.data.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
process_credit_grant_created
|
||||
when 'billing.credit_grant.updated'
|
||||
process_credit_grant_updated
|
||||
else
|
||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -28,15 +28,11 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
|
||||
create_checkout_session
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe API error: #{e.message}", error: e }
|
||||
rescue StandardError => e
|
||||
{ success: false, message: "Checkout error: #{e.message}", error: e }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_params
|
||||
raise StandardError, 'Pricing Plan ID required' if @pricing_plan_id.blank?
|
||||
|
||||
customer_id = custom_attribute('stripe_customer_id')
|
||||
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if customer_id.blank?
|
||||
end
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
module Enterprise::Billing::V2::Concerns::PaymentIntentHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_payment_if_needed(intent, intent_id)
|
||||
amount_due = intent.amount_details&.total || intent.amount_details.total
|
||||
return nil unless amount_due&.to_i&.positive?
|
||||
|
||||
payment_method_id = fetch_default_payment_method
|
||||
create_upfront_payment_intent(amount_due, intent.currency, payment_method_id, intent_id)
|
||||
end
|
||||
|
||||
def fetch_default_payment_method
|
||||
customer = Stripe::Customer.retrieve(
|
||||
@customer_id,
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
customer.invoice_settings&.default_payment_method
|
||||
end
|
||||
|
||||
def create_upfront_payment_intent(amount_due, currency, payment_method_id, intent_id)
|
||||
payment_intent = Stripe::PaymentIntent.create(
|
||||
payment_intent_params(amount_due, currency, payment_method_id, intent_id),
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
|
||||
payment_intent.id
|
||||
end
|
||||
|
||||
def payment_intent_params(amount_due, currency, payment_method_id, intent_id)
|
||||
{
|
||||
amount: amount_due,
|
||||
currency: currency || 'usd',
|
||||
customer: @customer_id,
|
||||
payment_method: payment_method_id,
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
allow_redirects: 'never'
|
||||
},
|
||||
confirm: true,
|
||||
off_session: true,
|
||||
metadata: { billing_intent_id: intent_id }
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_billing_intent(intent_id)
|
||||
StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/intents/#{intent_id}",
|
||||
{},
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
def commit_billing_intent(intent_id, payment_intent_id)
|
||||
commit_params = payment_intent_id ? { payment_intent: payment_intent_id } : {}
|
||||
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
"/v2/billing/intents/#{intent_id}/commit",
|
||||
commit_params,
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
end
|
||||
+14
-48
@@ -1,21 +1,5 @@
|
||||
class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService
|
||||
# Features for each plan tier (matching the plan hierarchy)
|
||||
STARTUP_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
].freeze
|
||||
|
||||
BUSINESS_FEATURES = (STARTUP_FEATURES + %w[sla custom_roles]).freeze
|
||||
|
||||
ENTERPRISE_FEATURES = (BUSINESS_FEATURES + %w[audit_logs disable_branding saml]).freeze
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
def provision(subscription_id:)
|
||||
# Retrieve pricing plan subscription details from Stripe V2 API
|
||||
@@ -102,8 +86,15 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
# Sync monthly credits based on plan
|
||||
sync_plan_credits(pricing_plan_id)
|
||||
|
||||
# Enable plan features based on plan
|
||||
enable_plan_features(pricing_plan_id)
|
||||
# Extract plan name and enable features using PlanFeatureManager
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
|
||||
if plan_definition
|
||||
plan_name = extract_plan_name(plan_definition)
|
||||
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||
end
|
||||
|
||||
# Reset captain usage after provisioning
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
def sync_plan_credits(pricing_plan_id)
|
||||
@@ -115,35 +106,10 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
.sync_monthly_credits(plan_credits.to_i)
|
||||
end
|
||||
|
||||
def enable_plan_features(pricing_plan_id)
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
|
||||
return unless plan_definition
|
||||
|
||||
plan_tier = extract_plan_tier(plan_definition)
|
||||
return unless plan_tier
|
||||
|
||||
features_to_enable = features_for_plan_tier(plan_tier)
|
||||
return if features_to_enable.empty?
|
||||
|
||||
# Enable each feature using the account method
|
||||
account.enable_features(*features_to_enable)
|
||||
end
|
||||
|
||||
def extract_plan_tier(plan_definition)
|
||||
plan_definition[:display_name].split.find { |word| %w[Startup Business Enterprise].include?(word) }
|
||||
end
|
||||
|
||||
def features_for_plan_tier(plan_tier)
|
||||
case plan_tier
|
||||
when 'Startup'
|
||||
STARTUP_FEATURES
|
||||
when 'Business'
|
||||
BUSINESS_FEATURES
|
||||
when 'Enterprise'
|
||||
ENTERPRISE_FEATURES
|
||||
else
|
||||
[]
|
||||
end
|
||||
def extract_plan_name(plan_definition)
|
||||
# Extract plan name like "Startup", "Business", or "Enterprise" from display_name
|
||||
# e.g., "Chatwoot Startup" -> "Startup"
|
||||
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
class Enterprise::Billing::V2::SubscriptionService < Enterprise::Billing::V2::BaseService
|
||||
def migrate_to_v2(plan_type: 'startup', monthly_credits: 100)
|
||||
return { success: false, message: 'Already on V2' } if v2_enabled?
|
||||
|
||||
with_locked_account do
|
||||
apply_migration_attributes(plan_type, monthly_credits)
|
||||
log_migration_grant(plan_type, monthly_credits)
|
||||
end
|
||||
|
||||
{ success: true, message: 'Successfully migrated to V2 billing' }
|
||||
rescue StandardError => e
|
||||
{ success: false, message: e.message }
|
||||
end
|
||||
|
||||
def update_plan(plan_type)
|
||||
return { success: false, message: 'Not on V2 billing' } unless v2_enabled?
|
||||
|
||||
update_custom_attributes('plan_name' => plan_type.capitalize)
|
||||
|
||||
{ success: true, plan: plan_type }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_migration_attributes(plan_type, credits)
|
||||
update_custom_attributes(
|
||||
'stripe_billing_version' => 2,
|
||||
'monthly_credits' => credits,
|
||||
'topup_credits' => 0,
|
||||
'plan_name' => plan_type.capitalize,
|
||||
'subscription_status' => 'active'
|
||||
)
|
||||
end
|
||||
|
||||
def log_migration_grant(plan_type, credits)
|
||||
log_credit_transaction(
|
||||
type: 'grant',
|
||||
amount: credits,
|
||||
credit_type: 'monthly',
|
||||
description: "Initial V2 migration grant - #{plan_type} plan",
|
||||
metadata: { 'source' => 'migration', 'plan_type' => plan_type }
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -12,8 +12,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe error: #{e.message}" }
|
||||
rescue StandardError => e
|
||||
{ success: false, message: "Topup error: #{e.message}" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,22 +1,62 @@
|
||||
class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::BaseService
|
||||
def process(event)
|
||||
case event.type
|
||||
class Enterprise::Billing::V2::WebhookHandlerService
|
||||
def perform(event:)
|
||||
return { success: false, message: 'Account not found' } if account.blank?
|
||||
|
||||
@event = event
|
||||
|
||||
case @event.type
|
||||
when 'v2.billing.pricing_plan_subscription.servicing_activated'
|
||||
Rails.logger.info "Handling subscription servicing activated event: #{event.related_object.id}"
|
||||
Rails.logger.info "Related object: #{event.related_object.inspect}"
|
||||
handle_subscription_servicing_activated(event.related_object.id)
|
||||
Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}"
|
||||
handle_subscription_servicing_activated(@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 }
|
||||
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 = StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
|
||||
{},
|
||||
stripe_api_options
|
||||
)
|
||||
return nil unless subscription&.billing_cadence
|
||||
|
||||
cadence = StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/cadences/#{subscription.billing_cadence}",
|
||||
{},
|
||||
stripe_api_options
|
||||
)
|
||||
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 stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,10 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
transcriptions = transcribe_audio
|
||||
Rails.logger.info "Audio transcription successful: #{transcriptions}"
|
||||
{ success: true, transcriptions: transcriptions }
|
||||
rescue StandardError => e
|
||||
# Refund credit if transcription failed
|
||||
refund_credit
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
@@ -26,6 +30,20 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def refund_credit
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(account: account)
|
||||
credit_service.check_and_use_credits(
|
||||
feature: 'ai_audio_transcription',
|
||||
amount: -1, # Negative to refund
|
||||
metadata: {
|
||||
'message_id' => message.id,
|
||||
'attachment_id' => attachment.id,
|
||||
'conversation_id' => message.conversation_id,
|
||||
'refund' => true
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
temp_dir = Rails.root.join('tmp/uploads')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
|
||||
@@ -19,24 +19,16 @@ RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
||||
end
|
||||
|
||||
it 'delegates v2 billing events to the v2 webhook handler' do
|
||||
account = create(:account)
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge(
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => 'cus_123'
|
||||
))
|
||||
|
||||
event_object = double('StripeObject', customer: 'cus_123')
|
||||
event = double('Stripe::Event', type: 'billing.credit_grant.created', data: double(object: event_object))
|
||||
handler_double = instance_double(Enterprise::Billing::V2::WebhookHandlerService, process: { success: true })
|
||||
event = double('Stripe::Event', type: 'v2.billing.pricing_plan_subscription.servicing_activated')
|
||||
handler_double = instance_double(Enterprise::Billing::V2::WebhookHandlerService, perform: { success: true })
|
||||
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(event)
|
||||
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new)
|
||||
allow(Enterprise::Billing::V2::WebhookHandlerService).to receive(:new).with(account: account).and_return(handler_double)
|
||||
allow(Enterprise::Billing::V2::WebhookHandlerService).to receive(:new).and_return(handler_double)
|
||||
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
|
||||
expect(Enterprise::Billing::V2::WebhookHandlerService).to have_received(:new).with(account: account)
|
||||
expect(handler_double).to have_received(:process).with(event)
|
||||
expect(Enterprise::Billing::V2::WebhookHandlerService).to have_received(:new)
|
||||
expect(handler_double).to have_received(:perform).with(event: event)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::V2::SubscriptionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
describe '#migrate_to_v2' do
|
||||
it 'updates account attributes and logs initial credit grant' do # rubocop:disable RSpec/MultipleExpectations
|
||||
result = nil
|
||||
|
||||
expect do
|
||||
result = service.migrate_to_v2(plan_type: 'startup', monthly_credits: 800)
|
||||
end.to change { account.reload.credit_transactions.count }.by(1)
|
||||
|
||||
account.reload
|
||||
expect(result).to include(success: true)
|
||||
expect(account.custom_attributes['stripe_billing_version']).to eq(2)
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(800)
|
||||
expect(account.custom_attributes['plan_name']).to eq('Startup')
|
||||
|
||||
transaction = account.credit_transactions.order(created_at: :desc).first
|
||||
expect(transaction.amount).to eq(800)
|
||||
expect(transaction.metadata['source']).to eq('migration')
|
||||
expect(transaction.metadata['plan_type']).to eq('startup')
|
||||
end
|
||||
|
||||
it 'uses default credits when not specified' do
|
||||
result = service.migrate_to_v2
|
||||
|
||||
expect(result).to include(success: true)
|
||||
expect(account.reload.custom_attributes['monthly_credits']).to eq(100)
|
||||
end
|
||||
|
||||
it 'returns error when already on v2' do
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
|
||||
|
||||
result = service.migrate_to_v2(monthly_credits: 500)
|
||||
|
||||
expect(result[:success]).to be(false)
|
||||
expect(result[:message]).to eq('Already on V2')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_plan' do
|
||||
it 'updates plan name when on v2 billing' do
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
|
||||
|
||||
result = service.update_plan('business')
|
||||
|
||||
expect(result).to include(success: true, plan: 'business')
|
||||
expect(account.reload.custom_attributes['plan_name']).to eq('Business')
|
||||
end
|
||||
|
||||
it 'returns error when account not migrated' do
|
||||
result = service.update_plan('business')
|
||||
|
||||
expect(result[:success]).to be(false)
|
||||
expect(result[:message]).to eq('Not on V2 billing')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,25 +2,44 @@ require 'rails_helper'
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
|
||||
describe Enterprise::Billing::V2::WebhookHandlerService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:account) do
|
||||
create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
|
||||
end
|
||||
let(:service) { described_class.new }
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).with(account: account).and_return(credit_service)
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
end
|
||||
|
||||
describe '#process' do
|
||||
describe '#perform' do
|
||||
context 'when handling subscription servicing activated' do
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: 'bpps_subscription_123',
|
||||
pricing_plan: 'bpp_business_plan_123',
|
||||
billing_cadence: 'cadence_123',
|
||||
component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }]
|
||||
)
|
||||
end
|
||||
|
||||
let(:cadence_response) do
|
||||
OpenStruct.new(
|
||||
id: 'cadence_123',
|
||||
payer: OpenStruct.new(customer: 'cus_123')
|
||||
)
|
||||
end
|
||||
|
||||
let(:provisioning_response) do
|
||||
{
|
||||
success: true,
|
||||
subscription_id: 'bpps_subscription_123',
|
||||
pricing_plan_id: 'bpp_business_plan_123',
|
||||
quantity: 5
|
||||
}
|
||||
end
|
||||
|
||||
let(:event) do
|
||||
double(
|
||||
'Stripe::Event',
|
||||
@@ -36,49 +55,47 @@ describe Enterprise::Billing::V2::WebhookHandlerService do
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: 'bpp_business_plan_123')
|
||||
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
|
||||
|
||||
# Mock account lookup via subscription and cadence
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
|
||||
.and_return(subscription_response)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
|
||||
.and_return(cadence_response)
|
||||
|
||||
# Mock provisioning service
|
||||
provisioning_service = instance_double(Enterprise::Billing::V2::SubscriptionProvisioningService)
|
||||
allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new).with(account: account).and_return(provisioning_service)
|
||||
allow(provisioning_service).to receive(:provision).and_return(provisioning_response)
|
||||
|
||||
allow(credit_service).to receive(:sync_monthly_credits)
|
||||
end
|
||||
|
||||
it 'returns success with subscription details' do
|
||||
result = service.process(event)
|
||||
it 'delegates to subscription provisioning service' do
|
||||
result = service.perform(event: event)
|
||||
|
||||
expect(result[:success]).to be(true), "Expected success but got: #{result.inspect}"
|
||||
expect(result[:subscription_id]).to eq('bpps_subscription_123')
|
||||
expect(result[:pricing_plan_id]).to eq('bpp_business_plan_123')
|
||||
expect(result[:quantity]).to eq(5)
|
||||
end
|
||||
|
||||
it 'updates account custom attributes' do
|
||||
service.process(event)
|
||||
|
||||
expect(account.custom_attributes['stripe_subscription_id']).to eq('bpps_subscription_123')
|
||||
expect(account.custom_attributes['stripe_pricing_plan_id']).to eq('bpp_business_plan_123')
|
||||
expect(account.custom_attributes['subscribed_quantity']).to eq(5)
|
||||
expect(account.custom_attributes['subscription_status']).to eq('active')
|
||||
expect(account.custom_attributes['plan_name']).to eq('Chatwoot Business')
|
||||
end
|
||||
|
||||
it 'syncs monthly credits' do
|
||||
service.process(event)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_credits).with(50_000)
|
||||
end
|
||||
|
||||
it 'provisions features for the plan' do
|
||||
service.process(event)
|
||||
|
||||
enabled_feature_names = account.enabled_features.map(&:first)
|
||||
expect(enabled_feature_names).to match_array(expected_features)
|
||||
expect(Enterprise::Billing::V2::SubscriptionProvisioningService).to have_received(:new).with(account: account)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling unknown event' do
|
||||
it 'returns success' do
|
||||
related_object = OpenStruct.new(id: 'unknown_id')
|
||||
related_object = OpenStruct.new(id: 'bpps_subscription_123')
|
||||
event = double('Stripe::Event', type: 'unknown.event', related_object: related_object)
|
||||
|
||||
result = service.process(event)
|
||||
# Mock account lookup
|
||||
subscription_response = OpenStruct.new(billing_cadence: 'cadence_123')
|
||||
cadence_response = OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123'))
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
|
||||
.and_return(subscription_response)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
|
||||
.and_return(cadence_response)
|
||||
|
||||
result = service.perform(event: event)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user