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 new file mode 100644 index 000000000..6fa71998a --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -0,0 +1,126 @@ +module Enterprise::Api::V1::Accounts::Concerns::BillingV2 + extend ActiveSupport::Concern + + included do + 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], + usage_this_month: balance[:usage_this_month], + usage_total: balance[:usage_total] + } + 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 } + end + + def v2_topup_options + options = Enterprise::Billing::V2::TopupCatalog.options + render json: { topup_options: options } + end + + def v2_topup + service = Enterprise::Billing::V2::TopupService.new(account: @account) + result = service.create_topup(credits: params[:credits].to_i) + + if result[:success] + render json: { success: true, message: result[:message] } + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + def v2_subscribe + service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account) + result = service.create_subscription_checkout( + pricing_plan_id: params[:pricing_plan_id], + quantity: subscription_quantity + ) + + if result[:success] + render json: { success: true, redirect_url: result[:redirect_url], checkout_session_id: result[:checkout_session_id] } + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + def cancel_subscription + service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account) + result = service.cancel_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 + end + + private + + def subscription_quantity + [params[:quantity].to_i, 1].max + end + + def validate_topup_amount + return if params[:credits].to_i.positive? + + render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity + end +end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index cc819e484..7fed4dfcf 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -1,10 +1,10 @@ -# rubocop:disable Metrics/ClassLength class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper + include Enterprise::Api::V1::Accounts::Concerns::BillingV2 + before_action :fetch_account before_action :check_authorization before_action :check_cloud_env, only: [:limits, :toggle_deletion] - before_action :validate_topup_amount, only: [:v2_topup] def subscription if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? @@ -57,114 +57,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController end end - # V2 Billing Endpoints - 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], - usage_this_month: balance[:usage_this_month], - usage_total: balance[:usage_total] - } - 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 } - end - - def v2_topup_options - options = Enterprise::Billing::V2::TopupCatalog.options - render json: { topup_options: options } - end - - def v2_topup - service = Enterprise::Billing::V2::TopupService.new(account: @account) - result = service.create_topup(credits: params[:credits].to_i) - - if result[:success] - render json: { success: true, message: result[:message] } - else - render json: { error: result[:message] }, status: :unprocessable_entity - end - end - - def v2_subscribe - service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account) - result = service.create_subscription_checkout( - pricing_plan_id: params[:pricing_plan_id], - quantity: subscription_quantity - ) - - if result[:success] - render json: { success: true, redirect_url: result[:redirect_url], checkout_session_id: result[:checkout_session_id] } - else - render json: { error: result[:message] }, status: :unprocessable_entity - end - end - - def cancel_subscription - service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account) - result = service.cancel_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 - end - private def check_cloud_env @@ -223,18 +115,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController render json: { redirect_url: redirect_url } end - def subscription_quantity - quantity = params[:quantity].to_i - quantity.positive? ? quantity : 1 - end - - def validate_topup_amount - amount = params[:credits].to_i - return if amount.positive? - - render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity - end - def pundit_user { user: current_user, @@ -243,4 +123,3 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController } end end -# rubocop:enable Metrics/ClassLength diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb index 228209a1f..7ade73e77 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - module Enterprise::Billing::Concerns::PlanFeatureManager extend ActiveSupport::Concern diff --git a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb index ec2dc123e..e300b83e7 100644 --- a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb @@ -13,8 +13,7 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base 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) + reserve_billing_intent(billing_intent) commit_billing_intent(billing_intent) update_account_plan(new_pricing_plan_id, quantity) success_response(new_pricing_plan_id, quantity) @@ -94,35 +93,6 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base ) 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, 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 8c42fb8ef..741ad3b99 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -115,7 +115,6 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil "pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}" attributes = { - 'stripe_billing_version' => 2, 'stripe_subscription_id' => subscription_id, 'subscribed_quantity' => quantity, 'subscription_status' => 'active', diff --git a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb index 6446adbcb..32c14a8b1 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -1,6 +1,7 @@ class Enterprise::Billing::V2::WebhookHandlerService def perform(event:) @event = event + return { success: false, message: 'Event is required' } if @event.blank? return { success: false, message: 'Account not found' } if account.blank? diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 725d5ef0e..278f41d71 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -15,10 +15,6 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService transcriptions = transcribe_audio Rails.logger.info "Audio transcription successful: #{transcriptions}" { success: true, transcriptions: transcriptions } - rescue StandardError => e - # Refund credit if transcription failed - refund_credit - raise e end private @@ -30,20 +26,6 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService account.usage_limits[:captain][:responses][:current_available].positive? end - def refund_credit - credit_service = Enterprise::Ai::CaptainCreditService.new(account: account) - credit_service.check_and_use_credits( - feature: 'ai_audio_transcription', - amount: -1, # Negative to refund - metadata: { - 'message_id' => message.id, - 'attachment_id' => attachment.id, - 'conversation_id' => message.conversation_id, - 'refund' => true - } - ) - end - def fetch_audio_file temp_dir = Rails.root.join('tmp/uploads') FileUtils.mkdir_p(temp_dir) diff --git a/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb index e9594fe85..270324dd0 100644 --- a/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb @@ -2,8 +2,14 @@ require 'rails_helper' require 'ostruct' describe Enterprise::Billing::V2::TopupService do - let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) } + let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Startups' }) } let(:service) { described_class.new(account: account) } + let(:invoice_settings) { instance_double(Stripe::InvoiceSettings, default_payment_method: 'pm_123') } + let(:stripe_customer) do + instance_double(Stripe::Customer, + invoice_settings: invoice_settings, + default_source: nil) + end let(:invoice) { Stripe::Invoice.construct_from(id: 'in_123', customer: 'cus_123', currency: 'usd') } let(:invoice_item) do Stripe::InvoiceItem.construct_from( @@ -30,6 +36,9 @@ describe Enterprise::Billing::V2::TopupService do currency: 'usd' ) + # Mock Stripe Customer retrieval for payment method check + allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer) + # Mock Stripe Invoice creation allow(Stripe::Invoice).to receive(:create).and_return(invoice)