Compare commits

...
Author SHA1 Message Date
Tanmay Sharma afa7f2d325 add default payment method for change of subscription 2025-11-24 19:38:38 +05:30
Tanmay Sharma 91bfc47f01 add change pricing plan view 2025-11-24 19:18:02 +05:30
Tanmay Sharma 862e33e7b9 Merge remote-tracking branch 'origin/stripe_v2/base' into stripe_v2/update_subscription 2025-11-24 19:16:49 +05:30
Tanmay Sharma 7d2307d343 fix spec handle stripe event service spec 2025-11-24 19:15:01 +05:30
Tanmay Deep Sharma 69b818896a update plan catalog display_name 2025-11-19 20:01:49 +05:30
Tanmay Deep Sharma 01d17674e6 fix comments 2025-11-19 19:39:40 +05:30
Tanmay Deep Sharma 689525ce85 fix credit addition for topup and monthly 2025-11-17 11:57:27 +05:30
Tanmay Sharma 6abecdfd43 update the logic to get the number of credits for a monthly plan 2025-11-17 11:15:21 +05:30
Tanmay Deep SharmaandGitHub 7ac8e857c7 Merge branch 'stripe_v2/base' into stripe_v2/update_subscription 2025-11-17 10:23:15 +05:30
Tanmay Deep Sharma 418d9cce27 update stripe controller rspec 2025-11-10 20:44:10 +05:30
Tanmay Deep Sharma eba12567e9 cursor review commit fixes 2025-11-10 19:15:20 +05:30
Tanmay Deep Sharma d230bb68d0 add updated rspecs 2025-11-10 19:06:24 +05:30
Tanmay Deep Sharma 7babcfe622 add updated rspecs 2025-11-10 17:55:57 +05:30
Tanmay Deep Sharma f31666f877 feat: add update subscription in controller 2025-11-10 17:42:09 +05:30
Tanmay Deep Sharma 4c1a047c94 feat: add changes to support update subscription 2025-11-10 17:14:59 +05:30
Tanmay Deep Sharma c853d07f02 init: base for stripe V2 2025-11-10 14:28:18 +05:30
34 changed files with 1851 additions and 257 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe'
gem 'stripe', '17.2.0.pre.alpha.2'
## - helper gems --##
## to populate db with sample data
+2 -2
View File
@@ -902,7 +902,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
stripe (17.2.0.pre.alpha.2)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1110,7 +1110,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe
stripe (= 17.2.0.pre.alpha.2)
telephone_number
test-prof
tidewave
+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)
+28
View File
@@ -30,4 +30,32 @@ class AccountPolicy < ApplicationPolicy
def toggle_deletion?
@account_user.administrator?
end
def v2_pricing_plans?
@account_user.administrator?
end
def v2_topup_options?
@account_user.administrator?
end
def v2_topup?
@account_user.administrator?
end
def v2_subscribe?
@account_user.administrator?
end
def cancel_subscription?
@account_user.administrator?
end
def credit_grants?
@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.subscription_status resource.custom_attributes['subscription_status']
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.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?
+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
+14
View File
@@ -434,6 +434,20 @@ Rails.application.routes.draw do
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
post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'
@@ -1,5 +1,6 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
@@ -0,0 +1,95 @@
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
service = Enterprise::Billing::V2::ChangePlanService.new(account: @account)
result = service.change_plan(
new_pricing_plan_id: params[:pricing_plan_id],
quantity: params[:quantity]&.to_i
)
if result[:success]
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
private
def fetch_account
@account = current_user.accounts.find(params[:account_id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def subscription_quantity
[params[:quantity].to_i, 1].max
end
def validate_topup_amount
return if params[:credits].to_i.positive?
render json: { error: I18n.t('errors.enterprise.billing.topup_amount_invalid') }, status: :unprocessable_entity
end
def pundit_user
{
user: current_user,
account: @account,
account_user: @current_account_user
}
end
def render_error(exception)
render json: { error: exception.message }, status: :unprocessable_entity
end
def render_not_implemented(exception)
render json: { error: exception.message }, status: :not_implemented
end
end
@@ -6,8 +6,17 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# Determine which webhook secret to use based on event type
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
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
@@ -18,4 +27,27 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# We've successfully processed the event without blowing up
head :ok
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
@@ -1,6 +1,13 @@
module Enterprise::Account::PlanUsageAndLimits
# Total credits
CAPTAIN_RESPONSES = 'captain_responses'.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_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
@@ -16,8 +23,7 @@ module Enterprise::Account::PlanUsageAndLimits
end
def increment_response_usage
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
custom_attributes[CAPTAIN_RESPONSES_USAGE] = (custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0) + 1
save
end
@@ -58,11 +64,12 @@ module Enterprise::Account::PlanUsageAndLimits
else
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
end
consumed = 0 if consumed.negative?
{
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),
consumed: consumed
}
@@ -96,17 +103,12 @@ module Enterprise::Account::PlanUsageAndLimits
end
def agent_limits
subscribed_quantity = custom_attributes['subscribed_quantity']
subscribed_quantity || get_limits(:agents)
custom_attributes['subscribed_quantity'] || get_limits(:agents)
end
def get_limits(limit_name)
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
ChatwootApp.max_limit
self[:limits][limit_name.to_s].presence || GlobalConfig.get(config_name)[config_name].presence || ChatwootApp.max_limit
end
def validate_limit_keys
@@ -119,7 +121,9 @@ module Enterprise::Account::PlanUsageAndLimits
'inboxes' => { 'type': 'number' },
'agents' => { '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' => [],
'additionalProperties' => false
@@ -0,0 +1,14 @@
module Enterprise::Billing::Concerns::BillingIntentWorkflow
extend ActiveSupport::Concern
private
# Execute a billing intent with automatic reserve and commit
def execute_billing_intent(intent_params)
intent = create_billing_intent(intent_params)
reserve_billing_intent(intent['id'])
yield(intent) if block_given?
commit_billing_intent(intent['id'])
intent
end
end
@@ -0,0 +1,24 @@
module Enterprise::Billing::Concerns::PlanDataHelper
extend ActiveSupport::Concern
private
def fetch_plan_version(plan_id)
plan = retrieve_pricing_plan(plan_id)
version = extract_attribute(plan, :latest_version)
raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank?
version
end
def fetch_plan_lookup_key(plan_id)
lookup_key = Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id)
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless lookup_key
lookup_key
end
def plan_display_name(plan_id)
Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan'
end
end
@@ -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,92 @@
module Enterprise::Billing::Concerns::ProrationLineItemBuilder
extend ActiveSupport::Concern
include Enterprise::Billing::Concerns::PlanDataHelper
private
def build_proration_line_items(context, proration_data)
return build_seat_change_line_items(context, proration_data) if seat_only_change?(context)
build_plan_change_line_items(context, proration_data)
end
def seat_only_change?(context)
!context[:plan_changed] && context[:seats_changed]
end
def build_seat_change_line_items(context, proration_data)
[{
amount: (proration_data[:net_amount] * 100).to_i,
description: build_seat_change_description(context),
metadata: build_seat_change_metadata(context, proration_data)
}]
end
def build_plan_change_line_items(context, proration_data)
line_items = []
old_plan_name = plan_display_name(context[:old_plan_id])
new_plan_name = plan_display_name(context[:target_plan_id])
line_items << build_credit_line_item(context, proration_data, old_plan_name) if proration_data[:credit_amount].positive?
line_items << build_charge_line_item(context, proration_data, new_plan_name) if proration_data[:charge_amount].positive?
line_items
end
def build_credit_line_item(context, proration_data, old_plan_name)
{
amount: -(proration_data[:credit_amount] * 100).to_i,
description: credit_description(old_plan_name, context[:old_quantity]),
metadata: credit_metadata(old_plan_name, context[:old_quantity], proration_data[:days_remaining])
}
end
def build_charge_line_item(context, proration_data, new_plan_name)
{
amount: (proration_data[:charge_amount] * 100).to_i,
description: charge_description(new_plan_name, context[:target_quantity]),
metadata: charge_metadata(new_plan_name, context[:target_quantity], proration_data[:days_remaining])
}
end
def credit_description(plan_name, quantity)
"Credit for unused time on #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
end
def charge_description(plan_name, quantity)
"Prorated charge for #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
end
def build_seat_change_description(context)
plan_name = plan_display_name(context[:target_plan_id])
change_type = context[:target_quantity] > context[:old_quantity] ? 'increase' : 'decrease'
quantity_diff = (context[:target_quantity] - context[:old_quantity]).abs
"Seat #{change_type} for #{plan_name}: #{context[:old_quantity]}#{context[:target_quantity]} " \
"seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})"
end
def base_metadata(type, days_remaining, **additional_fields)
{
type: type,
days_remaining: days_remaining,
billing_version: 'v2'
}.merge(additional_fields)
end
def credit_metadata(plan_name, quantity, days_remaining)
base_metadata('proration_credit', days_remaining, old_plan: plan_name, old_quantity: quantity)
end
def charge_metadata(plan_name, quantity, days_remaining)
base_metadata('proration_charge', days_remaining, new_plan: plan_name, new_quantity: quantity)
end
def build_seat_change_metadata(context, proration_data)
base_metadata('seat_change', proration_data[:days_remaining],
plan_name: plan_display_name(context[:target_plan_id]),
old_quantity: context[:old_quantity],
new_quantity: context[:target_quantity],
quantity_change: context[:target_quantity] - context[:old_quantity])
end
end
@@ -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
@@ -0,0 +1,41 @@
module Enterprise::Billing::Concerns::SubscriptionDataManager
extend ActiveSupport::Concern
private
def fetch_subscription_metadata
subscription_id = fetch_subscription_id
subscription = retrieve_pricing_plan_subscription(subscription_id)
cadence_id = extract_cadence_id(subscription)
cadence = retrieve_billing_cadence(cadence_id)
next_billing_date = extract_attribute(cadence, :next_billing_date)
store_next_billing_date(next_billing_date)
{
subscription_id: subscription_id,
subscription: subscription,
cadence_id: cadence_id,
cadence: cadence,
next_billing_date: next_billing_date
}
end
def fetch_subscription_id
subscription_id = custom_attribute('stripe_subscription_id')
raise StandardError, 'No pricing plan subscription ID found' if subscription_id.blank?
subscription_id
end
def extract_cadence_id(subscription)
cadence_id = extract_attribute(subscription, :billing_cadence)
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
cadence_id
end
def store_next_billing_date(next_billing_date)
update_custom_attributes({ 'next_billing_date' => next_billing_date })
end
end
@@ -1,4 +1,6 @@
class Enterprise::Billing::CreateStripeCustomerService
include Enterprise::Billing::Concerns::PlanFeatureManager
pattr_initialize [:account!]
DEFAULT_QUANTITY = 2
@@ -6,22 +8,11 @@ class Enterprise::Billing::CreateStripeCustomerService
def perform
return if existing_subscription?
raise_config_error unless v2_configs_present?
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']
}
)
update_account_for_v2_billing(customer_id)
enable_plan_specific_features('Hacker')
end
private
@@ -35,22 +26,16 @@ class Enterprise::Billing::CreateStripeCustomerService
customer_id
end
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
def v2_configs_present?
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').present?
end
def price_id
price_ids = default_plan['price_ids']
price_ids.first
def raise_config_error
raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required')
end
def existing_subscription?
@@ -66,4 +51,23 @@ class Enterprise::Billing::CreateStripeCustomerService
)
subscriptions.data.present?
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
@@ -1,30 +1,9 @@
class Enterprise::Billing::HandleStripeEventService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
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:)
@event = event
@@ -33,6 +12,8 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
when 'billing.credit_grant.created'
process_credit_grant_created
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
@@ -47,7 +28,8 @@ class Enterprise::Billing::HandleStripeEventService
return if plan.blank? || account.blank?
update_account_attributes(subscription, plan)
update_plan_features
plan_name = account.custom_attributes['plan_name']
update_plan_features(plan_name)
reset_captain_usage
end
@@ -73,65 +55,22 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
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
@subscription ||= @event.data.object
end
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
def find_plan(plan_id)
@@ -139,18 +78,60 @@ class Enterprise::Billing::HandleStripeEventService
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
end
def default_plan?
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
default_plan = cloud_plans.first || {}
account.custom_attributes['plan_name'] == default_plan['name']
def process_credit_grant_created
grant_id = extract_credit_grant_id(@event.data.object)
return if grant_id.blank?
# 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
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
def extract_credit_grant_id(grant_object)
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
end
# Enable each feature
account.enable_features(*features) if features.present?
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)
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
@@ -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,154 @@
class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::ProrationLineItemBuilder
include Enterprise::Billing::Concerns::StripeV2ClientHelper
include Enterprise::Billing::Concerns::PlanProvisioningHelper
include Enterprise::Billing::Concerns::BillingIntentWorkflow
include Enterprise::Billing::Concerns::SubscriptionDataManager
include Enterprise::Billing::Concerns::PlanDataHelper
def change_plan(new_pricing_plan_id: nil, quantity: nil)
validation_error = validate_parameters(new_pricing_plan_id, quantity)
return validation_error if validation_error
with_locked_account do
perform_subscription_change(new_pricing_plan_id, quantity)
end
end
private
def validate_parameters(new_pricing_plan_id, quantity)
return { success: false, message: 'Must specify either new_pricing_plan_id or quantity' } if new_pricing_plan_id.nil? && quantity.nil?
return { success: false, message: 'Invalid quantity' } if !quantity.nil? && !quantity.positive?
# Validate customer has a default payment method using common service
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_method_validation = payment_service.validate_payment_method
return payment_method_validation unless payment_method_validation.nil?
nil
end
def perform_subscription_change(new_pricing_plan_id, quantity)
change_context = build_change_context(new_pricing_plan_id, quantity)
return no_change_response(change_context) unless change_required?(change_context)
execute_change(change_context)
end
def build_change_context(new_pricing_plan_id, quantity)
old_plan_id = pricing_plan_id
old_quantity = subscribed_quantity
target_plan_id = new_pricing_plan_id || old_plan_id
target_quantity = quantity || old_quantity
{
old_plan_id: old_plan_id,
old_quantity: old_quantity,
target_plan_id: target_plan_id,
target_quantity: target_quantity,
plan_changed: old_plan_id != target_plan_id,
seats_changed: old_quantity != target_quantity
}
end
def change_required?(context)
context[:plan_changed] || context[:seats_changed]
end
def no_change_response(context)
plan_name = plan_display_name(context[:target_plan_id])
{ success: false, message: "Subscription already has plan #{plan_name} with #{context[:target_quantity]} seat(s)" }
end
def execute_change(context)
# Create and execute billing intent to update the Stripe subscription
intent_params = build_change_plan_params(context[:target_plan_id], context[:target_quantity])
execute_billing_intent(intent_params)
next_billing_date = custom_attribute('next_billing_date')
proration_data = calculate_proration(
old_plan_id: context[:old_plan_id],
new_plan_id: context[:target_plan_id],
old_quantity: context[:old_quantity],
new_quantity: context[:target_quantity],
next_billing_date: next_billing_date
)
line_items = build_proration_line_items(context, proration_data)
create_and_charge_invoice(line_items)
update_account_plan(context[:target_plan_id], context[:target_quantity], next_billing_date)
provision_new_plan(context[:target_plan_id]) if context[:plan_changed]
{ success: true, message: 'Plan change successful' }
end
def build_change_plan_params(new_pricing_plan_id, quantity)
metadata = fetch_subscription_metadata
plan_version = fetch_plan_version(new_pricing_plan_id)
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
{
cadence: metadata[:cadence_id],
currency: 'usd',
actions: [build_modify_action(metadata[:subscription_id], new_pricing_plan_id, plan_version, lookup_key, quantity)]
}
end
def build_modify_action(subscription_id, plan_id, plan_version, lookup_key, quantity)
{
type: 'modify',
modify: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: {
pricing_plan_subscription: subscription_id,
new_pricing_plan: plan_id,
new_pricing_plan_version: plan_version,
component_configurations: [{ lookup_key: lookup_key, quantity: quantity }]
}
}
}
end
def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:)
old_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(old_plan_id)&.dig(:base_fee) || 0.0
new_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(new_plan_id)&.dig(:base_fee) || 0.0
Enterprise::Billing::V2::ProrationCalculator.calculate(
old_plan_price: old_plan_price,
new_plan_price: new_plan_price,
old_quantity: old_quantity,
new_quantity: new_quantity,
next_billing_date: next_billing_date
)
end
def create_and_charge_invoice(line_items)
# Return success with no invoice if no line items (negligible proration)
return { success: true, amount: 0.0, message: 'No charges due to negligible proration' } if line_items.empty?
# Use common invoice payment service
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_service.create_and_pay_invoice(
line_items: line_items,
description: 'Proration charges for plan/seat changes',
metadata: {
account_id: account.id.to_s,
type: 'proration'
}
)
end
def update_account_plan(plan_id, quantity, next_billing_date)
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)
plan_name = plan_definition&.dig(:display_name)
update_custom_attributes({
'pricing_plan_id' => plan_id,
'subscribed_quantity' => quantity,
'plan_name' => plan_name,
'next_billing_date' => next_billing_date
})
end
end
@@ -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,138 @@
class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::BaseService
# Common service for creating invoices with line items and charging immediately
# Used by both TopupService and ChangePlanService
#
# @param line_items [Array<Hash>] Array of line items: [{ amount:, description:, metadata: }]
# @param description [String] Description for the invoice
# @param currency [String] Currency code (default: 'usd')
# @param metadata [Hash] Metadata for the invoice
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
# Validate that customer has a default payment method
# If no default is set but payment methods exist, automatically set the first one as default
# @return [Hash, nil] Returns error hash if validation fails, nil if success
def validate_payment_method
return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank?
customer = Stripe::Customer.retrieve(stripe_customer_id)
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
# No default payment method found - try to set one automatically
ensure_default_payment_method_result = ensure_default_payment_method(customer)
return ensure_default_payment_method_result unless ensure_default_payment_method_result.nil?
end
nil
rescue Stripe::StripeError => e
Rails.logger.error("Failed to check payment method: #{e.message}")
{ success: false, message: "Error validating payment method: #{e.message}" }
end
# Ensure a default payment method is set for the customer
# If payment methods exist but none is default, set the first one as default
# @param customer [Stripe::Customer] The Stripe customer object
# @return [Hash, nil] Returns error hash if no payment methods exist, nil if default is set successfully
def ensure_default_payment_method(customer)
payment_methods = fetch_customer_payment_methods(customer.id)
return no_payment_methods_error if payment_methods.data.empty?
set_first_payment_method_as_default(customer.id, payment_methods.data.first)
nil
rescue Stripe::StripeError => e
Rails.logger.error("Failed to set default payment method: #{e.message}")
{ success: false, message: "Error setting default payment method: #{e.message}" }
end
def fetch_customer_payment_methods(customer_id)
Stripe::PaymentMethod.list(customer: customer_id, limit: 100)
end
def no_payment_methods_error
{
success: false,
message: 'No payment methods found. Please add a payment method before making a purchase.'
}
end
def set_first_payment_method_as_default(customer_id, payment_method)
Stripe::Customer.update(
customer_id,
invoice_settings: { default_payment_method: payment_method.id }
)
Rails.logger.info("Automatically set payment method #{payment_method.id} as default for customer #{customer_id}")
end
# Create invoice with line items and charge immediately
#
# @param line_items [Array<Hash>] Line items: [{ amount: (cents), description:, metadata: }]
# @param description [String] Invoice description
# @param currency [String] Currency (default: 'usd')
# @param metadata [Hash] Invoice metadata
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {})
invoice = create_invoice(stripe_customer_id, currency, description, metadata)
add_line_items_to_invoice(invoice.id, stripe_customer_id, line_items, currency)
finalize_and_pay_invoice(invoice.id)
rescue Stripe::StripeError => e
Rails.logger.error("Error creating invoice: #{e.message}")
{ success: false, message: "Error creating invoice: #{e.message}" }
end
private
def create_invoice(customer_id, currency, description, metadata)
Stripe::Invoice.create({
customer: customer_id,
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false,
description: description,
metadata: metadata.stringify_keys
})
end
def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency)
line_items.each do |item|
Stripe::InvoiceItem.create({
customer: customer_id,
amount: item[:amount],
currency: currency,
invoice: invoice_id,
description: item[:description],
metadata: (item[:metadata] || {}).stringify_keys
})
end
end
# Finalize invoice and pay it immediately
# @param invoice_id [String] Stripe invoice ID
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def finalize_and_pay_invoice(invoice_id)
# Finalize the invoice
finalized_invoice = Stripe::Invoice.finalize_invoice(
invoice_id,
{ auto_advance: false }
)
# Pay the invoice immediately if not already paid
if finalized_invoice.status == 'paid'
build_invoice_response(finalized_invoice)
else
paid_invoice = Stripe::Invoice.pay(invoice_id, {})
build_invoice_response(paid_invoice)
end
rescue Stripe::StripeError => e
Rails.logger.error("Error finalizing/paying invoice: #{e.message}")
{ success: false, message: "Error processing payment: #{e.message}" }
end
def build_invoice_response(invoice)
{
success: true,
invoice_id: invoice.id,
invoice_url: invoice.hosted_invoice_url,
amount: invoice.total / 100.0,
status: invoice.status
}
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,99 @@
# rubocop:disable Style/ClassAndModuleChildren
module Enterprise
module Billing
module V2
class ProrationCalculator
# Calculate prorated amounts for subscription changes
#
# @param old_plan_price [Float] Price per unit of the old plan
# @param new_plan_price [Float] Price per unit of the new plan
# @param old_quantity [Integer] Old quantity/seats
# @param new_quantity [Integer] New quantity/seats
# @param next_billing_date [String/Time] Next billing date (ISO 8601)
# @return [Hash] { credit_amount:, charge_amount:, net_amount:, days_remaining:, total_days: }
#
def self.calculate(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
new(
old_plan_price: old_plan_price,
new_plan_price: new_plan_price,
old_quantity: old_quantity,
new_quantity: new_quantity,
next_billing_date: next_billing_date
).calculate
end
def initialize(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
@old_plan_price = old_plan_price.to_f
@new_plan_price = new_plan_price.to_f
@old_quantity = old_quantity.to_i
@new_quantity = new_quantity.to_i
@next_billing_date = parse_date(next_billing_date)
@current_date = Time.zone.now
end
def calculate
{
credit_amount: credit_amount,
charge_amount: charge_amount,
net_amount: net_amount,
days_remaining: days_remaining,
total_days: total_days,
proration_factor: proration_factor
}
end
private
def parse_date(date)
return date if date.is_a?(Time) || date.is_a?(DateTime)
Time.zone.parse(date.to_s)
rescue StandardError
raise ArgumentError, "Invalid next_billing_date format: #{date}"
end
# Credit from unused time on old plan
def credit_amount
@credit_amount ||= (@old_plan_price * @old_quantity * proration_factor).round(2)
end
# Charge for new plan for remaining time
def charge_amount
@charge_amount ||= (@new_plan_price * @new_quantity * proration_factor).round(2)
end
# Net amount to charge (positive) or credit (negative)
def net_amount
@net_amount ||= (charge_amount - credit_amount).round(2)
end
# Number of days remaining in current billing period
def days_remaining
@days_remaining ||= (@next_billing_date.to_date - @current_date.to_date).to_i
end
# Total days in the actual billing period (calculate from current cycle)
# This ensures proration factor never exceeds 1.0
def total_days
@total_days ||= begin
# Calculate the total days in this billing cycle
# Assume billing started one month ago from next_billing_date
billing_start = @next_billing_date - 1.month
((@next_billing_date.to_date - billing_start.to_date).to_i)
end
end
# Fraction of billing period remaining
# This value should always be between 0.0 and 1.0
def proration_factor
@proration_factor ||= begin
factor = days_remaining.to_f / total_days
# Ensure factor never exceeds 1.0
[factor, 1.0].min.round(4)
end
end
end
end
end
end
# rubocop:enable Style/ClassAndModuleChildren
@@ -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' => {},
'captain' => {
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit }
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
'monthly' => nil, 'topup' => nil },
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
'monthly' => 0, 'topup' => 0 }
},
'non_web_inboxes' => {}
}
@@ -2,24 +2,34 @@ require 'rails_helper'
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
describe 'POST /enterprise/webhooks/stripe' do
let(:params) { { content: 'hello' } }
let(:payload) { { type: 'customer.subscription.updated', content: 'hello' }.to_json }
let(:event_object) { instance_double(Stripe::Event, type: 'customer.subscription.updated') }
around do |example|
ENV['STRIPE_WEBHOOK_SECRET'] = 'test_secret'
ENV['STRIPE_WEBHOOK_SECRET_V2'] = 'test_secret_v2'
example.run
ENV.delete('STRIPE_WEBHOOK_SECRET')
ENV.delete('STRIPE_WEBHOOK_SECRET_V2')
end
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
handle_stripe = double
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
allow(Stripe::Webhook).to receive(:construct_event).and_return(event_object)
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
allow(handle_stripe).to receive(:perform)
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
expect(handle_stripe).to have_received(:perform).with(event: params)
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
expect(handle_stripe).to have_received(:perform).with(event: event_object)
end
it 'returns a bad request if the headers are missing' do
post '/enterprise/webhooks/stripe', params: params
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json' }
expect(response).to have_http_status(:bad_request)
end
it 'returns a bad request if the headers are invalid' do
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
allow(Stripe::Webhook).to receive(:construct_event).and_raise(Stripe::SignatureVerificationError.new('Invalid signature', 'sig'))
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
expect(response).to have_http_status(:bad_request)
end
end
@@ -1,139 +1,99 @@
require 'rails_helper'
describe Enterprise::Billing::CreateStripeCustomerService do
subject(:create_stripe_customer_service) { described_class }
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:stripe_customer_double) { instance_double(Stripe::Customer, id: 'cus_new') }
let(:subscriptions_list) { Stripe::ListObject.construct_from({ data: [] }) }
let(:hacker_plan_id) { 'price_hacker_random' }
describe '#perform' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).not_to have_received(:create)
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
before do
create(
:installation_config,
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{
stripe_customer_id: 'cus_random_number',
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
'name' => 'Hacker',
'limits' => {
'captain_responses_monthly' => 5,
'captain_responses_topup' => 0
}
}
]
)
it 'calls stripe methods to create a customer and updates the account' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: customer.id,
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id)
end
describe 'when checking for existing subscriptions' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
context 'when account has no stripe_customer_id' do
it 'creates a new subscription' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create)
expect(Stripe::Subscription).to have_received(:create)
end
end
context 'when account has stripe_customer_id' do
let(:stripe_customer_id) { 'cus_random_number' }
describe '#perform' do
context 'when account already has an active subscription' do
before do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
allow(Stripe::Subscription).to receive(:list)
.and_return(Stripe::ListObject.construct_from({ data: ['subscription'] }))
end
context 'when customer has active subscriptions' do
it 'returns without modifying the account or contacting Stripe' do
expect(Stripe::Customer).not_to receive(:create)
expect { service.perform }.not_to(change { account.reload.custom_attributes })
end
end
context 'when v2 billing configuration is missing' do
before do
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').destroy!
end
it 'raises an informative error' do
expect { service.perform }.to raise_error(
StandardError,
'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
)
end
end
context 'when account needs to be upgraded to v2 billing' do
context 'when stripe customer already exists' do
before do
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
allow(Stripe::Subscription).to receive(:create)
allow(Stripe::Customer).to receive(:create)
end
it 'does not create a new subscription' do
create_stripe_customer_service.new(account: account).perform
it 'does not create a new customer but updates custom attributes for v2 billing' do
service.perform
expect(Stripe::Subscription).not_to have_received(:create)
expect(Stripe::Subscription).to have_received(:list).with(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
expect(Stripe::Customer).not_to have_received(:create)
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_existing',
'stripe_pricing_plan_id' => hacker_plan_id,
'plan_name' => 'Hacker',
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
'stripe_billing_version' => 2
)
end
end
context 'when stripe customer does not exist' do
before do
allow(Stripe::Customer).to receive(:create).and_return(stripe_customer_double)
end
it 'creates a stripe customer and updates the account with v2 billing attributes' do
service.perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin.email })
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_new',
'stripe_pricing_plan_id' => hacker_plan_id,
'plan_name' => 'Hacker',
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
'stripe_billing_version' => 2
)
end
end
@@ -317,4 +317,146 @@ describe Enterprise::Billing::HandleStripeEventService do
end
end
end
describe 'credit grant handling' do
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
before do
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new)
.with(account: account).and_return(credit_service)
end
context 'when handling monthly credit grant' do
it 'adds credits from Stripe' do
allow(credit_service).to receive(:sync_monthly_response_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_123',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API (has complete amount structure)
api_grant_response = OpenStruct.new(
id: 'credgr_test_123',
customer: 'cus_123',
metadata: { 'credits' => '2000' },
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 2000)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_123')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:sync_monthly_response_credits).with(2000)
end
end
context 'when handling topup credit grant' do
it 'adds topup credits' do
allow(credit_service).to receive(:sync_monthly_response_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_456',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API (has complete amount structure)
api_grant_response = OpenStruct.new(
id: 'credgr_test_456',
customer: 'cus_123',
metadata: { 'credits' => '500' },
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 500)
),
expires_at: nil
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_456')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:sync_monthly_response_credits).with(500)
end
end
context 'when handling monetary type credit grant' do
it 'adds credits from monetary grant' do
allow(credit_service).to receive(:add_response_topup_credits)
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_monetary',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API with monetary amount
api_grant_response = OpenStruct.new(
id: 'credgr_test_monetary',
customer: 'cus_123',
metadata: { 'credits' => '1000' },
amount: OpenStruct.new(
type: 'monetary',
monetary: OpenStruct.new(
currency: 'usd',
value: 1000
)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_monetary')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_response_topup_credits).with(1000)
end
end
context 'when handling credit grant with zero amount' do
it 'does not call credit service' do
# Webhook event object (minimal, just has ID)
grant_event_object = OpenStruct.new(
id: 'credgr_test_zero',
customer: 'cus_123'
)
allow(event).to receive(:type).and_return('billing.credit_grant.created')
allow(data).to receive(:object).and_return(grant_event_object)
# Full grant object from API with zero amount
api_grant_response = OpenStruct.new(
id: 'credgr_test_zero',
customer: 'cus_123',
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 0)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_zero')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
# Ensure we don't accidentally call these methods
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
end
end
end
end