support changing pricing plans
This commit is contained in:
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user