From 4a0c61ddb7d30c7d8185b3eb72dd9373dfe5604d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 5 Nov 2025 16:38:29 +0530 Subject: [PATCH] use account limits to store the captain limits --- app/policies/account_policy.rb | 4 - config/routes.rb | 1 - .../api/v1/accounts/concerns/billing_v2.rb | 12 --- .../account/plan_usage_and_limits.rb | 26 ++--- .../concerns/plan_provisioning_helper.rb | 10 +- .../billing/handle_stripe_event_service.rb | 35 +------ .../enterprise/billing/v2/base_service.rb | 42 ++++++-- .../billing/v2/credit_management_service.rb | 48 ++++----- .../v2/subscription_provisioning_service.rb | 36 +------ .../v2/credit_management_service_spec.rb | 97 +++++++++++++++---- 10 files changed, 157 insertions(+), 154 deletions(-) diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 212af6c34..bd8fc7b98 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 28826d84c..d886d004a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb index 1d38466d2..af1ab46aa 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -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 diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb index ce03efa41..03809bd12 100644 --- a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb index 4f53a899c..cb5447c48 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb @@ -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) diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 820b2a001..d28fc2fac 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -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) diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb index 92da4f306..500101b28 100644 --- a/enterprise/app/services/enterprise/billing/v2/base_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -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) diff --git a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb index 77f7a93cf..adcc84589 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -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) || {} diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb index dd6e418eb..ef853aa12 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb index 1e1b74330..6cf600209 100644 --- a/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb @@ -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