From 6f9f2c32c9c536817e6c52bf631e93d0c1506872 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 29 Oct 2025 15:49:41 +0530 Subject: [PATCH 1/4] handle cancellation properly --- .../v2/subscription_provisioning_service.rb | 59 +++++++++++++++++-- .../billing/v2/webhook_handler_service.rb | 9 +++ 2 files changed, 64 insertions(+), 4 deletions(-) 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 64d684686..8c42fb8ef 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -7,8 +7,9 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil # Extract details from the subscription pricing_plan_id = extract_pricing_plan_id(subscription) quantity = extract_subscription_quantity(subscription) + billing_cadence = extract_billing_cadence(subscription) # Update account with subscription details - update_subscription_details(subscription_id, pricing_plan_id, quantity) + 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? @@ -18,6 +19,22 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil { success: false, message: "Stripe error: #{e.message}" } end + def refresh + subscription_id = account.custom_attributes['stripe_subscription_id'] + return if subscription_id.blank? + + subscription_plan = retrieve_pricing_plan_subscription(subscription_id) + servicing_status = servicing_status(subscription_plan) + pricing_plan_id = extract_pricing_plan_id(subscription_plan) + if servicing_status == 'canceled' + cancel_subscription + else + quantity = account.custom_attributes['subscribed_quantity'] + billing_cadence = extract_billing_cadence(subscription_plan) + update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence) + end + end + private def build_success_response(subscription_id, pricing_plan_id, quantity) @@ -30,6 +47,36 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil } end + def servicing_status(subscription_plan) + subscription_plan.respond_to?(:servicing_status) ? subscription_plan.servicing_status : subscription_plan['servicing_status'] + end + + def cancel_subscription + hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID') + pricing_plan_id = hacker_plan_config.value + + # Update subscription status and plan details + attributes = { + 'plan_name': 'Hacker', + 'stripe_pricing_plan_id': pricing_plan_id, + 'subscribed_quantity': 2, + 'stripe_subscription_id': nil, + 'billing_cadence': nil, + 'subscription_status': 'canceled' + } + update_custom_attributes(attributes) + + # Sync credits for Hacker plan (0 credits) + sync_plan_credits(pricing_plan_id) + + # Disable all premium features and save + disable_all_premium_features + account.save! + + # Reset captain usage + reset_captain_usage + end + def retrieve_pricing_plan_subscription(subscription_id) StripeV2Client.request( :get, @@ -44,6 +91,10 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan'] end + def extract_billing_cadence(subscription) + subscription.respond_to?(:billing_cadence) ? subscription.billing_cadence : subscription['billing_cadence'] + end + def extract_subscription_quantity(_subscription) # Get quantity from account custom_attributes (set during checkout) pending_quantity = account.custom_attributes['pending_subscription_quantity'] @@ -59,7 +110,7 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil 1 end - def update_subscription_details(subscription_id, pricing_plan_id, quantity) + def update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence) Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \ "pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}" @@ -69,7 +120,8 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil 'subscribed_quantity' => quantity, 'subscription_status' => 'active', 'pending_subscription_quantity' => nil, - 'pending_subscription_pricing_plan' => nil + 'pending_subscription_pricing_plan' => nil, + 'billing_cadence' => billing_cadence } attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present? @@ -99,7 +151,6 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil def sync_plan_credits(pricing_plan_id) plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id) - return unless plan_credits Enterprise::Billing::V2::CreditManagementService .new(account: account) 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 e5a7618b5..6446adbcb 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -8,6 +8,9 @@ class Enterprise::Billing::V2::WebhookHandlerService when 'v2.billing.pricing_plan_subscription.servicing_activated' Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}" handle_subscription_servicing_activated(@event.related_object.id) + when 'v2.billing.cadence.billed' + Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}" + refresh_account_subscription_details(@event.related_object.id) else { success: true } end @@ -56,6 +59,12 @@ class Enterprise::Billing::V2::WebhookHandlerService .provision(subscription_id: subscription_id) end + def refresh_account_subscription_details(_cadence_id) + Enterprise::Billing::V2::SubscriptionProvisioningService + .new(account: account) + .refresh + end + def stripe_api_options { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } end From e10ef03490a743e7b116f5eaff88cee1344880c5 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Wed, 29 Oct 2025 17:28:03 +0530 Subject: [PATCH 2/4] fix rspec for topup --- .../api/v1/accounts/concerns/billing_v2.rb | 126 ++++++++++++++++++ .../enterprise/api/v1/accounts_controller.rb | 125 +---------------- .../v2/subscription_provisioning_service.rb | 1 - .../billing/v2/topup_service_spec.rb | 11 +- 4 files changed, 138 insertions(+), 125 deletions(-) create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb 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/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/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) From b9f68fc83933121a06e30bf408571b7d9370e96d Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Wed, 29 Oct 2025 17:46:39 +0530 Subject: [PATCH 3/4] do not deduct or refund credits for audio transactions --- .../billing/concerns/plan_feature_manager.rb | 2 -- .../billing/v2/change_plan_service.rb | 32 +------------------ .../messages/audio_transcription_service.rb | 18 ----------- 3 files changed, 1 insertion(+), 51 deletions(-) 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/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) From 79082bea58e7663bd3f6faa3acdc3328ca10c931 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Wed, 29 Oct 2025 17:48:09 +0530 Subject: [PATCH 4/4] return failure in case event is blank --- .../services/enterprise/billing/v2/webhook_handler_service.rb | 1 + 1 file changed, 1 insertion(+) 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?