use account limits to store the captain limits

This commit is contained in:
Tanmay Deep Sharma
2025-11-05 16:38:29 +05:30
parent d3b4c7225a
commit 4a0c61ddb7
10 changed files with 157 additions and 154 deletions
-4
View File
@@ -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
-1
View File
@@ -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