use account limits to store the captain limits
This commit is contained in:
@@ -31,10 +31,6 @@ class AccountPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def credits_balance?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def v2_pricing_plans?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -432,7 +432,6 @@ Rails.application.routes.draw do
|
||||
get :limits
|
||||
post :toggle_deletion
|
||||
# V2 Billing endpoints
|
||||
get :credits_balance
|
||||
get :credit_grants
|
||||
get :v2_pricing_plans
|
||||
get :v2_topup_options
|
||||
|
||||
@@ -5,18 +5,6 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2
|
||||
before_action :validate_topup_amount, only: [:v2_topup]
|
||||
end
|
||||
|
||||
def credits_balance
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
|
||||
balance = service.credit_balance
|
||||
|
||||
render json: {
|
||||
id: @account.id,
|
||||
monthly_credits: balance[:monthly],
|
||||
topup_credits: balance[:topup],
|
||||
total_credits: balance[:total]
|
||||
}
|
||||
end
|
||||
|
||||
def credit_grants
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
|
||||
grants = service.fetch_credit_grants
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,12 +7,10 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||
sync_plan_credits(new_pricing_plan_id)
|
||||
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
|
||||
if plan_definition
|
||||
plan_name = extract_plan_name(plan_definition)
|
||||
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||
end
|
||||
return unless plan_definition
|
||||
|
||||
reset_captain_usage
|
||||
plan_name = extract_plan_name(plan_definition)
|
||||
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||
end
|
||||
|
||||
def sync_plan_credits(pricing_plan_id)
|
||||
@@ -20,7 +18,7 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||
|
||||
Enterprise::Billing::V2::CreditManagementService
|
||||
.new(account: account)
|
||||
.sync_monthly_credits(plan_credits.to_i)
|
||||
.sync_monthly_response_credits(plan_credits.to_i)
|
||||
end
|
||||
|
||||
def extract_plan_name(plan_definition)
|
||||
|
||||
@@ -14,8 +14,6 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
process_subscription_deleted
|
||||
when 'billing.credit_grant.created'
|
||||
process_credit_grant_created
|
||||
when 'billing.credit_grant.updated'
|
||||
process_credit_grant_updated
|
||||
else
|
||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||
end
|
||||
@@ -98,27 +96,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
return if amount.zero?
|
||||
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
|
||||
if grant.expires_at.present?
|
||||
service.sync_monthly_credits(amount)
|
||||
else
|
||||
service.add_topup_credits(amount)
|
||||
end
|
||||
end
|
||||
|
||||
def process_credit_grant_updated
|
||||
grant = @event.data.object
|
||||
# Check if grant has expired
|
||||
return unless grant.respond_to?(:expired_at) && grant.expired_at
|
||||
|
||||
# Grant has expired
|
||||
handle_credit_grant_expired
|
||||
|
||||
# Other updates (voided, amount changes, etc) - do nothing
|
||||
end
|
||||
|
||||
def handle_credit_grant_expired
|
||||
Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits
|
||||
service.add_response_topup_credits(amount)
|
||||
end
|
||||
|
||||
def extract_credit_grant_id(grant_object)
|
||||
@@ -136,17 +114,6 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
# Fallback: extract from amount object
|
||||
amount_data = extract_attribute(grant, :amount)
|
||||
return 0 unless amount_data
|
||||
|
||||
amount_type = extract_attribute(amount_data, :type)
|
||||
|
||||
case amount_type
|
||||
when 'monetary'
|
||||
extract_amount_value(amount_data, :monetary)
|
||||
when 'custom_pricing_unit'
|
||||
extract_amount_value(amount_data, :custom_pricing_unit)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def extract_attribute(object, attribute)
|
||||
|
||||
@@ -17,19 +17,43 @@ class Enterprise::Billing::V2::BaseService
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
|
||||
def monthly_credits
|
||||
custom_attribute('monthly_credits').to_i
|
||||
def response_monthly_credits
|
||||
account.limits&.[]('captain_responses_monthly').to_i
|
||||
end
|
||||
|
||||
def topup_credits
|
||||
custom_attribute('topup_credits').to_i
|
||||
def response_topup_credits
|
||||
account.limits&.[]('captain_responses_topup').to_i
|
||||
end
|
||||
|
||||
def update_credits(monthly: nil, topup: nil)
|
||||
updates = {}
|
||||
updates['monthly_credits'] = monthly unless monthly.nil?
|
||||
updates['topup_credits'] = topup unless topup.nil?
|
||||
update_custom_attributes(updates)
|
||||
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 unless monthly || topup
|
||||
|
||||
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?
|
||||
|
||||
current_limits = account.limits.present? ? account.limits.deep_dup : {}
|
||||
updates.each do |key, value|
|
||||
current_limits[key.to_s] = value
|
||||
end
|
||||
|
||||
account.update!(limits: current_limits)
|
||||
end
|
||||
|
||||
def update_custom_attributes(updates)
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
|
||||
def sync_monthly_credits(amount)
|
||||
# Sync monthly response credits (resets on billing cycle with topup preservation)
|
||||
def sync_monthly_response_credits(amount)
|
||||
with_locked_account do
|
||||
update_credits(monthly: amount)
|
||||
# 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
|
||||
|
||||
def add_topup_credits(amount)
|
||||
# Add topup credits for responses
|
||||
def add_response_topup_credits(amount)
|
||||
with_locked_account do
|
||||
update_credits(topup: topup_credits + amount)
|
||||
new_topup = response_topup_credits + amount
|
||||
update_response_credits(topup: new_topup)
|
||||
end
|
||||
end
|
||||
|
||||
def expire_monthly_credits
|
||||
with_locked_account do
|
||||
expired = monthly_credits
|
||||
update_credits(monthly: 0) if expired.positive?
|
||||
expired
|
||||
end
|
||||
end
|
||||
|
||||
def credit_balance
|
||||
{
|
||||
monthly: monthly_credits,
|
||||
topup: topup_credits,
|
||||
total: total_credits
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_credit_grants
|
||||
customer_id = stripe_customer_id
|
||||
return [] if customer_id.blank?
|
||||
@@ -45,12 +38,19 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
[]
|
||||
end
|
||||
|
||||
def total_credits
|
||||
monthly_credits + topup_credits
|
||||
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) || {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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:)
|
||||
@@ -13,7 +14,7 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
|
||||
|
||||
# Provision the subscription: sync credits and enable features
|
||||
provision_subscription(pricing_plan_id) if pricing_plan_id.present?
|
||||
provision_new_plan(pricing_plan_id) if pricing_plan_id.present?
|
||||
|
||||
build_success_response(subscription_id, pricing_plan_id, quantity)
|
||||
rescue Stripe::StripeError => e
|
||||
@@ -34,6 +35,7 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
billing_cadence = extract_billing_cadence(subscription_plan)
|
||||
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
|
||||
end
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
private
|
||||
@@ -73,9 +75,6 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
# Disable all premium features and save
|
||||
disable_all_premium_features
|
||||
account.save!
|
||||
|
||||
# Reset captain usage
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
def extract_pricing_plan_id(subscription)
|
||||
@@ -126,33 +125,4 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil
|
||||
|
||||
update_custom_attributes(attributes)
|
||||
end
|
||||
|
||||
def provision_subscription(pricing_plan_id)
|
||||
# Sync monthly credits based on plan
|
||||
sync_plan_credits(pricing_plan_id)
|
||||
|
||||
# Extract plan name and enable features using PlanFeatureManager
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
|
||||
if plan_definition
|
||||
plan_name = extract_plan_name(plan_definition)
|
||||
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||
end
|
||||
|
||||
# Reset captain usage after provisioning
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
def sync_plan_credits(pricing_plan_id)
|
||||
plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id)
|
||||
|
||||
Enterprise::Billing::V2::CreditManagementService
|
||||
.new(account: account)
|
||||
.sync_monthly_credits(plan_credits.to_i)
|
||||
end
|
||||
|
||||
def extract_plan_name(plan_definition)
|
||||
# Extract plan name like "Startup", "Business", or "Enterprise" from display_name
|
||||
# e.g., "Chatwoot Startup" -> "Startup"
|
||||
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,43 +10,100 @@ describe Enterprise::Billing::V2::CreditManagementService do
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts',
|
||||
'monthly_credits' => 100,
|
||||
'topup_credits' => 50
|
||||
'captain_responses_monthly' => 100,
|
||||
'captain_responses_topup' => 50,
|
||||
'captain_responses_usage' => 0,
|
||||
'captain_documents_usage' => 0
|
||||
},
|
||||
limits: {
|
||||
'captain_responses' => 150,
|
||||
'captain_documents' => 200
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
describe '#total_credits' do
|
||||
it 'returns sum of monthly and topup credits' do
|
||||
expect(service.total_credits).to eq(150)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_monthly_credits' do
|
||||
it 'updates monthly credits' do
|
||||
service.sync_monthly_credits(500)
|
||||
describe '#sync_monthly_response_credits' do
|
||||
it 'updates monthly credits and syncs total' do
|
||||
service.sync_monthly_response_credits(500)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(500)
|
||||
expect(account.custom_attributes['captain_responses_monthly']).to eq(500)
|
||||
expect(account.limits['captain_responses']).to eq(550) # 500 + 50 topup (stored in limits ONLY)
|
||||
end
|
||||
|
||||
it 'preserves topup credits capped at remaining balance' do
|
||||
# Set usage to 80
|
||||
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 80))
|
||||
|
||||
service.sync_monthly_response_credits(100)
|
||||
|
||||
account.reload
|
||||
# Current: monthly=100, topup=50, total=150, usage=80
|
||||
# Remaining: 150 - 80 = 70
|
||||
# Topup should be capped at min(50, 70) = 50
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(50)
|
||||
expect(account.limits['captain_responses']).to eq(150)
|
||||
end
|
||||
|
||||
it 'caps topup when usage exceeds new monthly allocation' do
|
||||
# Usage is 120, new monthly is 100, topup is 50
|
||||
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 120))
|
||||
|
||||
service.sync_monthly_response_credits(100)
|
||||
|
||||
account.reload
|
||||
# New total would be: monthly=100 + topup=50 = 150
|
||||
# Usage: 120
|
||||
# Remaining: 150 - 120 = 30
|
||||
# Topup should be capped at min(50, 30) = 30
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(30)
|
||||
expect(account.limits['captain_responses']).to eq(130) # 100 + 30
|
||||
end
|
||||
|
||||
it 'zeros topup when usage exceeds new total' do
|
||||
# Usage is 160, new monthly is 100, topup is 50
|
||||
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 160))
|
||||
|
||||
service.sync_monthly_response_credits(100)
|
||||
|
||||
account.reload
|
||||
# New total would be: monthly=100 + topup=50 = 150
|
||||
# Usage: 160 (exceeds total)
|
||||
# Remaining: max(0, 150 - 160) = 0
|
||||
# Topup should be: min(50, 0) = 0
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(0)
|
||||
expect(account.limits['captain_responses']).to eq(100) # 100 + 0
|
||||
end
|
||||
end
|
||||
|
||||
describe '#expire_monthly_credits' do
|
||||
it 'expires monthly credits' do
|
||||
expired = service.expire_monthly_credits
|
||||
describe '#sync_document_credits' do
|
||||
it 'updates document credits total' do
|
||||
service.sync_document_credits(500)
|
||||
|
||||
account.reload
|
||||
expect(account.limits['captain_documents']).to eq(500) # Stored in limits hash ONLY
|
||||
end
|
||||
end
|
||||
|
||||
describe '#expire_monthly_response_credits' do
|
||||
it 'expires monthly credits and preserves topup' do
|
||||
expired = service.expire_monthly_response_credits
|
||||
|
||||
expect(expired).to eq(100)
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(0)
|
||||
expect(account.custom_attributes['captain_responses_monthly']).to eq(0)
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(50)
|
||||
expect(account.limits['captain_responses']).to eq(50)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_topup_credits' do
|
||||
it 'adds topup credits' do
|
||||
service.add_topup_credits(100)
|
||||
describe '#add_response_topup_credits' do
|
||||
it 'adds topup credits and updates total' do
|
||||
service.add_response_topup_credits(100)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['topup_credits']).to eq(150)
|
||||
expect(account.custom_attributes['captain_responses_topup']).to eq(150) # 50 + 100
|
||||
expect(account.limits['captain_responses']).to eq(250) # 100 monthly + 150 topup
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user