support changing pricing plans

This commit is contained in:
Tanmay Deep Sharma
2025-10-28 22:22:48 +05:30
parent 3ba4340042
commit 36a2412f42
14 changed files with 805 additions and 67 deletions
+2 -2
View File
@@ -20,8 +20,8 @@
class CreditTransaction < ApplicationRecord
belongs_to :account
validates :amount, presence: true, numericality: { greater_than: 0 }
validates :transaction_type, presence: true, inclusion: { in: %w[grant expire use topup] }
validates :amount, presence: true, numericality: true
validates :transaction_type, presence: true, inclusion: { in: %w[grant expire use topup refund] }
validates :credit_type, presence: true, inclusion: { in: %w[monthly topup mixed] }
scope :recent, -> { order(created_at: :desc) }
+13 -1
View File
@@ -55,7 +55,19 @@ class AccountPolicy < ApplicationPolicy
@account_user.administrator?
end
def update_subscription?
def credit_grants?
@account_user.administrator?
end
def resume_subscription?
@account_user.administrator?
end
def update_subscription_quantity?
@account_user.administrator?
end
def change_pricing_plan?
@account_user.administrator?
end
end
+4
View File
@@ -433,11 +433,15 @@ Rails.application.routes.draw do
post :toggle_deletion
# V2 Billing endpoints
get :credits_balance
get :credit_grants
get :v2_pricing_plans
get :v2_topup_options
post :v2_topup
post :v2_subscribe
post :cancel_subscription
post :resume_subscription
post :update_subscription_quantity
post :change_pricing_plan
end
end
end
@@ -1,3 +1,4 @@
# rubocop:disable Metrics/ClassLength
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
@@ -71,6 +72,13 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
}
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 }
@@ -111,7 +119,63 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
result = service.cancel_subscription
if result[:success]
render json: result
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def resume_subscription
service = Enterprise::Billing::V2::ResumeSubscriptionService.new(account: @account)
result = service.resume_subscription
if result[:success]
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def update_subscription_quantity
service = Enterprise::Billing::V2::UpdateSubscriptionService.new(account: @account)
result = service.update_quantity(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
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
@@ -195,3 +259,4 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
}
end
end
# rubocop:enable Metrics/ClassLength
@@ -12,7 +12,9 @@ class Enterprise::Ai::CaptainCreditService
# V2 accounts use credit-based billing
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
service.use_credit(feature: feature, amount: amount, metadata: metadata)
result = service.use_credit(feature: feature, amount: amount, metadata: metadata)
Rails.logger.info "Credit result: #{result.inspect}"
result
end
private
@@ -1,9 +1,9 @@
class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
# Cancel subscription at period end using Stripe's V1 API
# Cancel subscription using Stripe's V2 Billing Intent API
# Creates a deactivate billing intent for the pricing plan subscription
# Subscription remains active until the end of the current billing period
# Customer receives no refund/credit for remaining time
#
# @return [Hash] { success:, cancel_at_period_end:, period_end:, message: }
#
@@ -12,9 +12,11 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
return { success: false, message: 'No active subscription' } unless active_subscription?
with_locked_account do
subscription_data = cancel_at_period_end
update_account_status(subscription_data)
success_response = build_success_response(subscription_data)
billing_intent = create_deactivate_intent
reserve_billing_intent(billing_intent)
commit_billing_intent(billing_intent)
update_account_status(billing_intent)
success_response = build_success_response(billing_intent)
success_response
end
rescue Stripe::StripeError => e
@@ -25,45 +27,102 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::
private
def cancel_at_period_end
subscription_id = custom_attribute('stripe_subscription_id')
raise StandardError, 'No subscription ID found' if subscription_id.blank?
# Build update parameters to cancel at period end
params = {
cancel_at_period_end: true
}
def create_deactivate_intent
pricing_plan_subscription_id = fetch_subscription_id
billing_cadence_id = fetch_billing_cadence_id(pricing_plan_subscription_id)
store_next_billing_date(billing_cadence_id)
StripeV2Client.request(
:post,
"/v1/subscriptions/#{subscription_id}",
params,
'/v2/billing/intents',
build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id),
stripe_api_options
)
end
def update_account_status(subscription_data)
# Extract period end from subscription data
current_period_end = extract_attribute(subscription_data, :current_period_end)
period_end_time = current_period_end ? Time.zone.at(current_period_end).iso8601 : nil
def fetch_subscription_id
custom_attribute('stripe_subscription_id').tap do |id|
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
end
end
def fetch_billing_cadence_id(subscription_id)
subscription = retrieve_pricing_plan_subscription(subscription_id)
extract_attribute(subscription, :billing_cadence).tap do |cadence_id|
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
end
end
def store_next_billing_date(cadence_id)
cadence = retrieve_billing_cadence(cadence_id)
@next_billing_date = extract_attribute(cadence, :next_billing_date)
end
def build_deactivate_params(subscription_id, cadence_id)
{
cadence: cadence_id,
currency: 'usd',
actions: [{
type: 'deactivate',
deactivate: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: { pricing_plan_subscription: subscription_id }
}
}]
}
end
def reserve_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/reserve",
{},
stripe_api_options
)
end
def commit_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/commit",
{},
stripe_api_options
)
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def retrieve_billing_cadence(cadence_id)
StripeV2Client.request(
:get,
"/v2/billing/cadences/#{cadence_id}",
{},
stripe_api_options
)
end
def update_account_status(_billing_intent)
# Mark subscription as cancelling (will be cancelled at period end)
# Store next_billing_date so the UI can show when the subscription ends
update_custom_attributes({
'subscription_status' => 'cancel_at_period_end',
'subscription_cancelled_at' => Time.current.iso8601,
'subscription_period_end' => period_end_time
'subscription_ends_at' => @next_billing_date
})
end
def build_success_response(subscription_data)
current_period_end = extract_attribute(subscription_data, :current_period_end)
period_end_time = current_period_end ? Time.zone.at(current_period_end) : nil
def build_success_response(_billing_intent)
{
success: true,
cancel_at_period_end: true,
period_end: period_end_time,
message: 'Subscription will be cancelled at the end of the current billing period.'
message: 'Subscription cancellation initiated. It will be deactivated at the end of the current billing period.'
}
end
@@ -0,0 +1,197 @@
class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
# Change customer's pricing plan using Stripe's V2 Billing Intent API
# Creates a modify billing intent to migrate to a new pricing plan
#
# @param new_pricing_plan_id [String] The new Stripe pricing plan ID
# @param quantity [Integer] The quantity for the new plan
# @return [Hash] { success:, message: }
#
def change_plan(new_pricing_plan_id:, quantity: 1)
return { success: false, message: 'Not a V2 billing account' } unless v2_enabled?
return { success: false, message: 'No active subscription' } unless active_subscription?
return { success: false, message: 'Invalid quantity' } unless quantity.positive?
with_locked_account do
billing_intent = create_change_plan_intent(new_pricing_plan_id, quantity)
reserved_intent = reserve_billing_intent(billing_intent)
handle_payment_if_needed(reserved_intent)
commit_billing_intent(billing_intent)
update_account_plan(new_pricing_plan_id, quantity)
success_response(new_pricing_plan_id, quantity)
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
end
private
def create_change_plan_intent(new_pricing_plan_id, quantity)
subscription_id = fetch_subscription_id
cadence_id = fetch_cadence_from_subscription(subscription_id)
plan_version = fetch_new_plan_version(new_pricing_plan_id)
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
component_config = { lookup_key: lookup_key, quantity: quantity }
StripeV2Client.request(
:post,
'/v2/billing/intents',
build_change_plan_params(subscription_id, cadence_id, new_pricing_plan_id, plan_version, component_config),
stripe_api_options
)
end
def fetch_subscription_id
custom_attribute('stripe_subscription_id').tap do |id|
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
end
end
def fetch_cadence_from_subscription(subscription_id)
subscription = retrieve_pricing_plan_subscription(subscription_id)
extract_attribute(subscription, :billing_cadence).tap do |cadence_id|
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
end
end
def fetch_new_plan_version(plan_id)
plan = retrieve_pricing_plan(plan_id)
extract_attribute(plan, :latest_version).tap do |version|
raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank?
end
end
def fetch_plan_lookup_key(plan_id)
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id).tap do |key|
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless key
end
end
def build_change_plan_params(subscription_id, cadence_id, plan_id, plan_version, component_config)
{
cadence: cadence_id,
currency: 'usd',
actions: [{
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: [component_config]
}
}
}]
}
end
def reserve_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/reserve",
{},
stripe_api_options
)
end
def handle_payment_if_needed(reserved_intent)
# Check if there's a proration charge that needs to be paid
total_amount = extract_attribute(reserved_intent['amount_details'], 'total')
return if total_amount.nil? || total_amount.to_i <= 0
# Get customer's default payment method
stripe_customer_id = custom_attribute('stripe_customer_id')
raise StandardError, 'No Stripe customer ID found' if stripe_customer_id.blank?
customer = Stripe::Customer.retrieve(stripe_customer_id)
payment_method_id = customer.invoice_settings&.default_payment_method
raise StandardError, 'No default payment method found. Please add a payment method first.' if payment_method_id.blank?
# Create and confirm payment intent for proration charge
Stripe::PaymentIntent.create({
amount: total_amount.to_i,
currency: 'usd',
customer: stripe_customer_id,
payment_method: payment_method_id,
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
metadata: {
account_id: account.id,
type: 'plan_change_proration'
}
})
end
def commit_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/commit",
{},
stripe_api_options
)
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def retrieve_pricing_plan(pricing_plan_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plans/#{pricing_plan_id}",
{},
stripe_api_options
)
end
def update_account_plan(new_pricing_plan_id, quantity)
attributes = {
'pending_stripe_pricing_plan_id' => new_pricing_plan_id,
'pending_subscription_quantity' => quantity
}
# Add plan name from catalog
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
def extract_plan_name(plan_definition)
# Extract plan name like "Startup", "Business", or "Enterprise" from display_name
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
end
def success_response(new_pricing_plan_id, quantity)
{
success: true,
pricing_plan_id: new_pricing_plan_id,
quantity: quantity,
message: 'Pricing plan changed successfully'
}
end
def v2_enabled?
custom_attribute('stripe_billing_version')&.to_i == 2
end
def active_subscription?
custom_attribute('subscription_status') == 'active'
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
end
end
@@ -1,29 +1,46 @@
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
# rubocop:disable Metrics/MethodLength
def use_credit(feature: 'ai_captain', amount: 1, metadata: {})
return { success: true, credits_used: 0, remaining: total_credits } if amount <= 0
with_locked_account do
return { success: false, message: 'Insufficient credits' } unless total_credits >= amount
# Handle refunds (negative amounts)
if amount.negative?
refund_credits(amount.abs)
log_credit_transaction(
type: 'use',
amount: amount,
credit_type: 'topup',
description: "Refund for #{feature}",
metadata: metadata
)
{ success: true, credits_refunded: amount.abs, remaining: total_credits }
elsif total_credits < amount
# Handle usage (positive amounts)
{ success: false, message: 'Insufficient credits' }
else
# Report usage to Stripe
stripe_result = report_usage_to_stripe(amount, feature)
# Report usage to Stripe
stripe_result = report_usage_to_stripe(amount, feature)
return { success: false, message: stripe_result[:message] } unless stripe_result[:success]
if stripe_result[:success]
# Deduct credits locally (monthly first, then topup)
deduct_credits(amount)
# Deduct credits locally (monthly first, then topup)
deduct_credits(amount)
# Log transaction
log_credit_transaction(
type: 'use',
amount: amount,
credit_type: determine_credit_type(amount),
description: "Used for #{feature}",
metadata: metadata
)
# Log transaction
log_credit_transaction(
type: 'use',
amount: amount,
credit_type: determine_credit_type(amount),
description: "Used for #{feature}",
metadata: metadata
)
{ success: true, credits_used: amount, remaining: total_credits }
{ success: true, credits_used: amount, remaining: total_credits }
else
{ success: false, message: stripe_result[:message] }
end
end
end
end
# rubocop:enable Metrics/MethodLength
def sync_monthly_credits(amount, metadata: {})
with_locked_account do
@@ -73,6 +90,23 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
}
end
def fetch_credit_grants
customer_id = stripe_customer_id
return [] if customer_id.blank?
response = Stripe::Billing::CreditGrant.list(
{ customer: customer_id, limit: 100 },
stripe_api_options
)
response.data.map do |grant|
transform_credit_grant(grant)
end
rescue Stripe::StripeError => e
Rails.logger.error("Failed to fetch credit grants: #{e.message}")
[]
end
def calculate_usage_stats
month_start = Time.current.beginning_of_month
@@ -98,6 +132,65 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
private
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
def transform_credit_grant(grant)
# Stripe objects have symbol keys when converted to hash, or access via methods
# Use the grant object directly for most reliable access
category = grant[:category] || grant.category
metadata = grant[:metadata] || grant.metadata || {}
# Determine credits amount based on grant type
credits = if category == 'paid' && metadata['credits']
metadata['credits'].to_i
elsif category == 'promotional'
grant_id = grant[:id] || grant.id
fetch_monthly_credit_amount(grant_id)
else
0
end
{
id: grant[:id] || grant.id,
name: grant[:name] || grant.name,
credits: credits,
category: category,
source: metadata['source'] || category,
effective_at: parse_timestamp(grant[:effective_at] || grant.effective_at),
expires_at: parse_timestamp(grant[:expires_at] || grant.expires_at),
voided_at: parse_timestamp(grant[:voided_at] || grant.voided_at),
created_at: parse_timestamp(grant[:created] || grant.created)
}
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
def parse_timestamp(timestamp)
return nil unless timestamp
Time.zone.at(timestamp)
end
def fetch_monthly_credit_amount(grant_id)
# Try to get from credit transactions with matching grant_id in metadata
transaction = account.credit_transactions
.where(transaction_type: 'grant', credit_type: 'monthly')
.where("metadata->>'grant_id' = ?", grant_id)
.order(created_at: :desc)
.first
return transaction.amount if transaction
# Fallback: return the current monthly_credits from account
monthly_credits
end
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def report_usage_to_stripe(amount, feature)
Enterprise::Billing::V2::UsageReporterService.new(account: account).report(amount, feature)
end
@@ -111,6 +204,11 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
end
end
def refund_credits(amount)
# Refunds are added to topup credits
update_credits(topup: topup_credits + amount)
end
def determine_credit_type(amount)
if monthly_credits >= amount
'monthly'
@@ -25,7 +25,7 @@ module Enterprise::Billing::V2::PlanCatalog
base_fee: 39.0,
monthly_credits: 50_000,
config_key: 'STRIPE_BUSINESS_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_business_license_fee_v2'
licensed_item_lookup_key: 'chatwoot_business_plan_license_fee_v2'
},
{
key: :enterprise,
@@ -0,0 +1,149 @@
class Enterprise::Billing::V2::ResumeSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
# Resume a cancelled subscription by creating an activate billing intent
# This reactivates the subscription to continue beyond the current period
#
# @return [Hash] { success:, message: }
#
def resume_subscription
return { success: false, message: 'Not a V2 billing account' } unless v2_enabled?
return { success: false, message: 'Subscription is not pending cancellation' } unless cancelling_subscription?
with_locked_account do
billing_intent = create_activate_intent
reserve_billing_intent(billing_intent)
commit_billing_intent(billing_intent)
update_account_status(billing_intent)
success_response = build_success_response(billing_intent)
success_response
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
rescue StandardError => e
{ success: false, message: "Resume error: #{e.message}" }
end
private
def create_activate_intent
subscription_id = fetch_subscription_id
plan_id = fetch_plan_id
cadence_id = fetch_cadence_from_subscription(subscription_id)
plan_version = fetch_plan_version(plan_id)
StripeV2Client.request(
:post,
'/v2/billing/intents',
build_activate_params(cadence_id, plan_id, plan_version),
stripe_api_options
)
end
def fetch_subscription_id
custom_attribute('stripe_subscription_id').tap do |id|
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
end
end
def fetch_plan_id
custom_attribute('stripe_pricing_plan_id')
end
def fetch_cadence_from_subscription(subscription_id)
subscription = retrieve_pricing_plan_subscription(subscription_id)
extract_attribute(subscription, :billing_cadence).tap do |cadence_id|
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
end
end
def fetch_plan_version(plan_id)
plan = retrieve_pricing_plan(plan_id)
extract_attribute(plan, :latest_version)
end
def build_activate_params(cadence_id, plan_id, plan_version)
{
cadence: cadence_id,
currency: 'usd',
actions: [{
type: 'subscribe',
subscribe: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: {
pricing_plan: plan_id,
pricing_plan_version: plan_version
}
}
}]
}
end
def reserve_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/reserve",
{},
stripe_api_options
)
end
def commit_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/commit",
{},
stripe_api_options
)
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def retrieve_pricing_plan(pricing_plan_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plans/#{pricing_plan_id}",
{},
stripe_api_options
)
end
def update_account_status(_billing_intent)
# Mark subscription as active again (remove cancel_at_period_end flag and end date)
update_custom_attributes({
'subscription_status' => 'active',
'subscription_cancelled_at' => nil,
'subscription_ends_at' => nil
})
end
def build_success_response(_billing_intent)
{
success: true,
message: 'Subscription resumed successfully. It will now continue beyond the current billing period.'
}
end
def v2_enabled?
custom_attribute('stripe_billing_version')&.to_i == 2
end
def cancelling_subscription?
custom_attribute('subscription_status') == 'cancel_at_period_end'
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
end
end
@@ -16,8 +16,6 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
build_success_response(subscription_id, pricing_plan_id, quantity)
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
rescue StandardError => e
{ success: false, message: "Provisioning error: #{e.message}" }
end
private
@@ -43,8 +41,7 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
def extract_pricing_plan_id(subscription)
# Extract pricing_plan from the subscription object
pricing_plan = subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan']
pricing_plan.is_a?(String) ? pricing_plan : pricing_plan&.[]('id') || pricing_plan&.dig(:id)
subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan']
end
def extract_subscription_quantity(_subscription)
@@ -54,8 +51,11 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{pending_quantity}"
return pending_quantity.to_i
end
Rails.logger.warn '[V2 Billing] No pending_subscription_quantity found in custom_attributes, defaulting to quantity=1'
subscribed_quantity = account.custom_attributes['subscribed_quantity']
if subscribed_quantity.present? && subscribed_quantity.to_i.positive?
Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{subscribed_quantity}"
return subscribed_quantity.to_i
end
1
end
@@ -23,9 +23,23 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi
return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition
return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank?
# Check if customer has a default payment method
unless customer_has_payment_method?
return { valid: false, success: false, message: 'No payment method found. Please add a payment method before making a purchase.' }
end
{ valid: true, topup_definition: topup_definition }
end
def customer_has_payment_method?
customer = Stripe::Customer.retrieve(stripe_customer_id, stripe_api_options)
# Check if customer has a default payment method or any payment methods attached
customer.invoice_settings&.default_payment_method.present? || customer.default_source.present?
rescue Stripe::StripeError => e
Rails.logger.error("Failed to check payment method: #{e.message}")
false
end
def process_topup_transaction(credits, amount_cents, currency, amount)
invoice = create_topup_invoice(currency)
return { success: false, message: 'Failed to create invoice' } unless invoice
@@ -0,0 +1,147 @@
class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
# Update subscription quantity using Stripe's V2 Billing Intent API
# Creates a modify billing intent to change the subscription quantity
#
# @param quantity [Integer] The new quantity
# @return [Hash] { success:, message: }
#
def update_quantity(quantity:)
return { success: false, message: 'Not a V2 billing account' } unless v2_enabled?
return { success: false, message: 'No active subscription' } unless active_subscription?
return { success: false, message: 'Invalid quantity' } unless quantity.positive?
with_locked_account do
billing_intent = create_modify_intent(quantity)
reserve_billing_intent(billing_intent)
commit_billing_intent(billing_intent)
update_account_quantity(quantity)
success_response(quantity)
end
rescue Stripe::StripeError => e
{ success: false, message: "Stripe error: #{e.message}" }
rescue StandardError => e
{ success: false, message: "Update error: #{e.message}" }
end
private
def create_modify_intent(quantity)
subscription_id = fetch_subscription_id
subscription = retrieve_pricing_plan_subscription(subscription_id)
cadence_id, plan_id, plan_version = extract_subscription_details(subscription)
lookup_key = fetch_plan_lookup_key(plan_id)
component_config = { lookup_key: lookup_key, quantity: quantity }
params = build_modify_params(subscription_id, cadence_id, plan_id, plan_version, component_config)
log_modify_intent(params)
StripeV2Client.request(:post, '/v2/billing/intents', params, stripe_api_options)
end
def fetch_subscription_id
custom_attribute('stripe_subscription_id').tap do |id|
raise StandardError, 'No pricing plan subscription ID found' if id.blank?
end
end
def extract_subscription_details(subscription)
cadence = extract_attribute(subscription, :billing_cadence)
plan = extract_attribute(subscription, :pricing_plan)
version = extract_attribute(subscription, :pricing_plan_version)
raise StandardError, 'No billing cadence found in subscription' if cadence.blank?
raise StandardError, 'No pricing plan found in subscription' if plan.blank?
raise StandardError, 'No pricing plan version found in subscription' if version.blank?
[cadence, plan, version]
end
def fetch_plan_lookup_key(plan_id)
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id).tap do |key|
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless key
end
end
def build_modify_params(subscription_id, cadence_id, plan_id, plan_version, component_config)
{
cadence: cadence_id,
currency: 'usd',
actions: [{
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: [component_config]
}
}
}]
}
end
def log_modify_intent(params)
Rails.logger.info "Create modify intent params: #{params.inspect}"
Rails.logger.info "Stripe API options: #{stripe_api_options.inspect}"
end
def reserve_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/reserve",
{},
stripe_api_options
)
end
def commit_billing_intent(billing_intent)
StripeV2Client.request(
:post,
"/v2/billing/intents/#{billing_intent.id}/commit",
{},
stripe_api_options
)
end
def retrieve_pricing_plan_subscription(subscription_id)
StripeV2Client.request(
:get,
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
{},
stripe_api_options
)
end
def update_account_quantity(quantity)
update_custom_attributes({
'pending_subscription_quantity' => quantity
})
end
def success_response(quantity)
{
success: true,
quantity: quantity,
message: "Subscription quantity updated to #{quantity}"
}
end
def v2_enabled?
custom_attribute('stripe_billing_version')&.to_i == 2
end
def active_subscription?
custom_attribute('subscription_status') == 'active'
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
def extract_attribute(object, key)
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
end
end
@@ -16,12 +16,12 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
private
def valid_configuration?
custom_attribute('stripe_customer_id').present? && meter_event_name.present?
custom_attribute('stripe_customer_id').present?
end
def meter_event_params(credits_used)
{
event_name: meter_event_name,
event_name: 'chatwoot.usage',
payload: {
value: credits_used.to_s,
stripe_customer_id: custom_attribute('stripe_customer_id')
@@ -30,15 +30,6 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
}
end
def meter_event_name
# Use shared meter event name from ENV/InstallationConfig (preferred)
# Falls back to account-specific event name for backward compatibility
shared_event_name = InstallationConfig.find_by(name: 'STRIPE_METER_EVENT_NAME')&.value ||
ENV.fetch('STRIPE_METER_EVENT_NAME', nil)
shared_event_name || custom_attribute('stripe_meter_event_name')
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end