Merge remote-tracking branch 'origin/stripe_v2/base' into stripe_v2/update_subscription

This commit is contained in:
Tanmay Sharma
2025-11-24 19:16:49 +05:30
19 changed files with 166 additions and 161 deletions
+4 -1
View File
@@ -8,7 +8,10 @@ module BillingHelper
# Return false if not plans are configured, so that no checks are enforced
return false if default_plan.blank?
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
# Handle both string and hash formats for default_plan
default_plan_name = default_plan.is_a?(Hash) ? default_plan['name'] : default_plan
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan_name
end
def conversations_this_month(account)
+11
View File
@@ -119,6 +119,12 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
enterprise:
billing:
topup_amount_invalid: Topup amount must be greater than 0
stripe_customer_required: Customer ID required. Please create a Stripe customer first.
lookup_key_not_found: Lookup key not found for pricing plan %{pricing_plan_id}
v2_configuration_required: V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.
profile:
mfa:
enabled: MFA enabled successfully
@@ -435,3 +441,8 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
enterprise:
billing:
topup_successful: Topup successful
subscription_cancelled: Subscription cancelled
pricing_plan_changed: Pricing plan changed
+11 -5
View File
@@ -431,12 +431,18 @@ Rails.application.routes.draw do
post :subscription
get :limits
post :toggle_deletion
# V2 Billing endpoints
end
end
end
namespace :v2 do
resources :accounts, only: [] do
resource :billing, only: [] do
get :credit_grants
get :v2_pricing_plans
get :v2_topup_options
post :v2_topup
post :v2_subscribe
get :pricing_plans
get :topup_options
post :topup
post :subscribe
post :cancel_subscription
post :change_pricing_plan
end
@@ -1,77 +0,0 @@
module Enterprise::Api::V1::Accounts::Concerns::BillingV2
extend ActiveSupport::Concern
included do
before_action :validate_topup_amount, only: [:v2_topup]
end
def credit_grants
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
grants = service.fetch_credit_grants
render json: { credit_grants: grants }
end
def v2_pricing_plans
plans = Enterprise::Billing::V2::PlanCatalog.plans
render json: { pricing_plans: plans }
end
def v2_topup_options
options = Enterprise::Billing::V2::TopupCatalog.options
render json: { topup_options: options }
end
def v2_topup
render json: { success: true, message: 'Topup successful.' }
end
def v2_subscribe
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
result = service.create_subscription_checkout(
pricing_plan_id: params[:pricing_plan_id],
quantity: subscription_quantity
)
if result[:success]
render json: { success: true, redirect_url: result[:redirect_url] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def cancel_subscription
render json: { success: true, message: 'Subscription cancelled.' }
end
def change_pricing_plan
service = Enterprise::Billing::V2::ChangePlanService.new(account: @account)
result = service.change_plan(
new_pricing_plan_id: params[:pricing_plan_id],
quantity: params[:quantity]&.to_i
)
if result[:success]
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
private
def subscription_quantity
[params[:quantity].to_i, 1].max
end
def validate_topup_amount
return if params[:credits].to_i.positive?
render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity
end
end
@@ -1,6 +1,5 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
include Enterprise::Api::V1::Accounts::Concerns::BillingV2
before_action :fetch_account
before_action :check_authorization
@@ -0,0 +1,80 @@
class Enterprise::Api::V2::BillingController < Api::BaseController
before_action :fetch_account
before_action :check_authorization
before_action :validate_topup_amount, only: [:topup]
rescue_from StandardError, with: :render_error
rescue_from NotImplementedError, with: :render_not_implemented
def credit_grants
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
grants = service.fetch_credit_grants
render json: { credit_grants: grants }
end
def pricing_plans
plans = Enterprise::Billing::V2::PlanCatalog.plans
render json: { pricing_plans: plans }
end
def topup_options
options = Enterprise::Billing::V2::TopupCatalog.options
render json: { topup_options: options }
end
def topup
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
@@ -35,6 +35,8 @@ class Enterprise::Webhooks::StripeController < ActionController::API
parsed_payload = JSON.parse(payload)
event_type = parsed_payload['type']
return ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) if event_type.blank?
if v2_billing_event?(event_type)
ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil)
else
@@ -43,7 +45,9 @@ class Enterprise::Webhooks::StripeController < ActionController::API
end
def v2_billing_event?(event_type)
return false if event_type.blank?
Rails.logger.debug { "V2 billing event: #{event_type}" }
event_type.start_with?('v2.') if event_type.present?
event_type.start_with?('v2.')
end
end
@@ -46,10 +46,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager
end
def enable_features_for_current_plan(plan_name)
# First disable all premium features to handle downgrades
disable_all_premium_features
# Then enable features based on the current plan
enable_plan_specific_features(plan_name)
end
@@ -58,7 +55,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager
# Enable features based on plan hierarchy
case plan_name
when 'Startup', 'Startups'
when 'Startups'
# Startup plan gets the basic features
account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
@@ -4,39 +4,10 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper
private
def provision_new_plan(new_pricing_plan_id)
sync_plan_credits(new_pricing_plan_id)
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
return unless plan_definition
plan_name = extract_plan_name(plan_definition)
plan_name = plan_definition[:display_name]
enable_plan_specific_features(plan_name) if plan_name.present?
end
def sync_plan_credits(pricing_plan_id)
plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id)
Enterprise::Billing::V2::CreditManagementService
.new(account: account)
.sync_monthly_response_credits(plan_credits.to_i)
end
def extract_plan_name(plan_definition)
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
end
def update_account_plan(new_pricing_plan_id, quantity, next_billing_date)
attributes = {
'stripe_pricing_plan_id' => new_pricing_plan_id,
'pending_stripe_pricing_plan_id' => nil,
'pending_subscription_quantity' => nil,
'subscribed_quantity' => quantity,
'next_billing_date' => next_billing_date
}
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
update_custom_attributes(attributes)
end
end
@@ -60,6 +60,8 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper
end
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
return object.public_send(key) if object.respond_to?(key)
object[key.to_s]
end
end
@@ -35,7 +35,7 @@ class Enterprise::Billing::CreateStripeCustomerService
end
def raise_config_error
raise StandardError, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required')
end
def existing_subscription?
@@ -89,14 +89,29 @@ class Enterprise::Billing::HandleStripeEventService
amount = extract_credit_amount(grant)
return if amount.zero?
grant_type = extract_grant_type(grant)
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
service.add_response_topup_credits(amount)
if grant_type == 'monetary'
service.add_response_topup_credits(amount)
else
service.sync_monthly_response_credits(amount)
end
end
def extract_credit_grant_id(grant_object)
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
end
def extract_grant_type(grant)
amount_object = extract_attribute(grant, :amount)
return 'monetary' if amount_object.blank?
type = extract_attribute(amount_object, :type)
return 'monetary' if type.blank?
type
end
def extract_credit_amount(grant)
# First, try to get credits from metadata
metadata = extract_attribute(grant, :metadata)
@@ -104,8 +119,16 @@ class Enterprise::Billing::HandleStripeEventService
credits = extract_attribute(metadata, :credits)
return credits.to_i if credits.present? && credits.to_i.positive?
end
amount = extract_attribute(grant, :amount)
return 0 if amount.blank?
0
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)
@@ -28,7 +28,7 @@ class Enterprise::Billing::V2::BaseService
# Update response credits (monthly/topup with auto-calculation of total)
def update_response_credits(monthly: nil, topup: nil)
# Calculate and update total in limits hash ONLY
return unless monthly || topup
return if monthly.nil? && topup.nil?
new_monthly = monthly || response_monthly_credits
new_topup = topup || response_topup_credits
@@ -44,23 +44,13 @@ class Enterprise::Billing::V2::BaseService
def update_limits(updates)
return if updates.blank?
current_limits = account.limits.present? ? account.limits.deep_dup : {}
updates.each do |key, value|
current_limits[key.to_s] = value
end
account.update!(limits: current_limits)
account.update!(limits: (account.limits || {}).merge(updates.transform_keys(&:to_s)))
end
def update_custom_attributes(updates)
return if updates.blank?
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
updates.each do |key, value|
current_attributes[key.to_s] = value
end
account.update!(custom_attributes: current_attributes)
account.update!(custom_attributes: (account.custom_attributes || {}).merge(updates.transform_keys(&:to_s)))
end
def custom_attribute(key)
@@ -13,13 +13,13 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
validate_params
store_pending_subscription_quantity
session = create_checkout_session(checkout_session_params)
{ success: true, redirect_url: session.url }
session.url
end
private
def validate_params
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank?
raise StandardError, I18n.t('errors.enterprise.billing.stripe_customer_required') if stripe_customer_id.blank?
end
def store_pending_subscription_quantity
@@ -50,7 +50,7 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2:
def build_checkout_items
lookup_key = extract_license_lookup_key
raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key
raise StandardError, I18n.t('errors.enterprise.billing.lookup_key_not_found', pricing_plan_id: @pricing_plan_id) unless lookup_key
[
{
@@ -32,7 +32,7 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
end
grants.reject { |grant| grant[:credits].zero? }
rescue Stripe::StripeError => e
Rails.logger.error("Failed to fetch credit grants: #{e.message}")
ChatwootExceptionTracker.new(e, account: account).capture_exception
[]
end
@@ -5,7 +5,7 @@ module Enterprise::Billing::V2::PlanCatalog
PLAN_DEFINITIONS = [
{
key: :free,
display_name: 'Chatwoot Hacker',
display_name: 'Hacker',
base_fee: 0.0,
monthly_credits: 0,
config_key: 'STRIPE_HACKER_PLAN_ID',
@@ -13,7 +13,7 @@ module Enterprise::Billing::V2::PlanCatalog
},
{
key: :startup,
display_name: 'Chatwoot Startup',
display_name: 'Startups',
base_fee: 19.0,
monthly_credits: 300,
config_key: 'STRIPE_STARTUP_PLAN_ID',
@@ -21,7 +21,7 @@ module Enterprise::Billing::V2::PlanCatalog
},
{
key: :business,
display_name: 'Chatwoot Business',
display_name: 'Business',
base_fee: 39.0,
monthly_credits: 500,
config_key: 'STRIPE_BUSINESS_PLAN_ID',
@@ -29,7 +29,7 @@ module Enterprise::Billing::V2::PlanCatalog
},
{
key: :enterprise,
display_name: 'Chatwoot Enterprise',
display_name: 'Enterprise',
base_fee: 99.0,
monthly_credits: 800,
config_key: 'STRIPE_ENTERPRISE_PLAN_ID',
@@ -53,11 +53,6 @@ module Enterprise::Billing::V2::PlanCatalog
nil
end
def monthly_credits_for(plan_id)
definition = definition_for(plan_id)
definition ? definition[:monthly_credits] : nil
end
def plan_id_for(definition)
InstallationConfig.find_by(name: definition[:config_key])&.value
end
@@ -49,6 +49,8 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
def cancel_subscription
hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID')
return if hacker_plan_config.nil?
pricing_plan_id = hacker_plan_config.value
# Update subscription status and plan details
@@ -63,7 +65,9 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
update_custom_attributes(attributes)
# Sync credits for Hacker plan (0 credits)
sync_plan_credits(pricing_plan_id)
Enterprise::Billing::V2::CreditManagementService
.new(account: account)
.sync_monthly_response_credits(0)
# Disable all premium features and save
disable_all_premium_features
@@ -3,9 +3,8 @@ class Enterprise::Billing::V2::WebhookHandlerService
def perform(event:)
@event = event
return { success: false, message: 'Event is required' } if @event.blank?
return { success: false, message: 'Account not found' } if account.blank?
raise StandardError, 'Event is required' if @event.blank?
raise StandardError, 'Account not found' if account.blank?
case @event.type
when 'v2.billing.pricing_plan_subscription.servicing_activated'
@@ -14,12 +13,10 @@ class Enterprise::Billing::V2::WebhookHandlerService
when 'v2.billing.cadence.billed'
Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}"
refresh_account_subscription_details(@event.related_object.id)
else
{ success: true }
end
rescue StandardError => e
Rails.logger.error "Error processing V2 webhook: #{e.message}"
{ success: false, error: e.message }
ChatwootExceptionTracker.new(e, account: account).capture_exception
raise
end
private
@@ -328,7 +328,7 @@ describe Enterprise::Billing::HandleStripeEventService do
context 'when handling monthly credit grant' do
it 'adds credits from Stripe' do
allow(credit_service).to receive(:add_response_topup_credits)
allow(credit_service).to receive(:sync_monthly_response_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
@@ -355,13 +355,13 @@ describe Enterprise::Billing::HandleStripeEventService do
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_response_topup_credits).with(2000)
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(:add_response_topup_credits)
allow(credit_service).to receive(:sync_monthly_response_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
@@ -388,7 +388,7 @@ describe Enterprise::Billing::HandleStripeEventService do
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_response_topup_credits).with(500)
expect(credit_service).to have_received(:sync_monthly_response_credits).with(500)
end
end