From 36a2412f4263fd1837103056498d6e54c6ccef67 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 28 Oct 2025 22:22:48 +0530 Subject: [PATCH] support changing pricing plans --- app/models/credit_transaction.rb | 4 +- app/policies/account_policy.rb | 14 +- config/routes.rb | 4 + .../enterprise/api/v1/accounts_controller.rb | 67 +++++- .../enterprise/ai/captain_credit_service.rb | 4 +- .../billing/v2/cancel_subscription_service.rb | 111 +++++++--- .../billing/v2/change_plan_service.rb | 197 ++++++++++++++++++ .../billing/v2/credit_management_service.rb | 134 ++++++++++-- .../enterprise/billing/v2/plan_catalog.rb | 2 +- .../billing/v2/resume_subscription_service.rb | 149 +++++++++++++ .../v2/subscription_provisioning_service.rb | 12 +- .../enterprise/billing/v2/topup_service.rb | 14 ++ .../billing/v2/update_subscription_service.rb | 147 +++++++++++++ .../billing/v2/usage_reporter_service.rb | 13 +- 14 files changed, 805 insertions(+), 67 deletions(-) create mode 100644 enterprise/app/services/enterprise/billing/v2/change_plan_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/resume_subscription_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb diff --git a/app/models/credit_transaction.rb b/app/models/credit_transaction.rb index 048131653..096f2cb67 100644 --- a/app/models/credit_transaction.rb +++ b/app/models/credit_transaction.rb @@ -20,8 +20,8 @@ class CreditTransaction < ApplicationRecord belongs_to :account - validates :amount, presence: true, numericality: { greater_than: 0 } - validates :transaction_type, presence: true, inclusion: { in: %w[grant expire use topup] } + validates :amount, presence: true, numericality: true + validates :transaction_type, presence: true, inclusion: { in: %w[grant expire use topup refund] } validates :credit_type, presence: true, inclusion: { in: %w[monthly topup mixed] } scope :recent, -> { order(created_at: :desc) } diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 17e4f7564..705de8d3d 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -55,7 +55,19 @@ class AccountPolicy < ApplicationPolicy @account_user.administrator? end - def update_subscription? + def credit_grants? + @account_user.administrator? + end + + def resume_subscription? + @account_user.administrator? + end + + def update_subscription_quantity? + @account_user.administrator? + end + + def change_pricing_plan? @account_user.administrator? end end diff --git a/config/routes.rb b/config/routes.rb index 602157836..28f0f4465 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -433,11 +433,15 @@ Rails.application.routes.draw do post :toggle_deletion # V2 Billing endpoints get :credits_balance + get :credit_grants get :v2_pricing_plans get :v2_topup_options post :v2_topup post :v2_subscribe post :cancel_subscription + post :resume_subscription + post :update_subscription_quantity + post :change_pricing_plan end 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 b246be254..f3d39ed7b 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -1,3 +1,4 @@ +# rubocop:disable Metrics/ClassLength class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper before_action :fetch_account @@ -71,6 +72,13 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController } 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 } @@ -111,7 +119,63 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController result = service.cancel_subscription if result[:success] - render json: result + # 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 resume_subscription + service = Enterprise::Billing::V2::ResumeSubscriptionService.new(account: @account) + result = service.resume_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 @@ -195,3 +259,4 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController } end end +# rubocop:enable Metrics/ClassLength diff --git a/enterprise/app/services/enterprise/ai/captain_credit_service.rb b/enterprise/app/services/enterprise/ai/captain_credit_service.rb index 8a2bd4d5a..41937065c 100644 --- a/enterprise/app/services/enterprise/ai/captain_credit_service.rb +++ b/enterprise/app/services/enterprise/ai/captain_credit_service.rb @@ -12,7 +12,9 @@ class Enterprise::Ai::CaptainCreditService # V2 accounts use credit-based billing service = Enterprise::Billing::V2::CreditManagementService.new(account: account) - service.use_credit(feature: feature, amount: amount, metadata: metadata) + result = service.use_credit(feature: feature, amount: amount, metadata: metadata) + Rails.logger.info "Credit result: #{result.inspect}" + result end private diff --git a/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb index 5f7fde2ae..98fd5a707 100644 --- a/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb @@ -1,9 +1,9 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService include Enterprise::Billing::Concerns::PlanFeatureManager - # Cancel subscription at period end using Stripe's V1 API + # Cancel subscription using Stripe's V2 Billing Intent API + # Creates a deactivate billing intent for the pricing plan subscription # Subscription remains active until the end of the current billing period - # Customer receives no refund/credit for remaining time # # @return [Hash] { success:, cancel_at_period_end:, period_end:, message: } # @@ -12,9 +12,11 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: return { success: false, message: 'No active subscription' } unless active_subscription? with_locked_account do - subscription_data = cancel_at_period_end - update_account_status(subscription_data) - success_response = build_success_response(subscription_data) + billing_intent = create_deactivate_intent + reserve_billing_intent(billing_intent) + commit_billing_intent(billing_intent) + update_account_status(billing_intent) + success_response = build_success_response(billing_intent) success_response end rescue Stripe::StripeError => e @@ -25,45 +27,102 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: private - def cancel_at_period_end - subscription_id = custom_attribute('stripe_subscription_id') - raise StandardError, 'No subscription ID found' if subscription_id.blank? - - # Build update parameters to cancel at period end - params = { - cancel_at_period_end: true - } + def create_deactivate_intent + pricing_plan_subscription_id = fetch_subscription_id + billing_cadence_id = fetch_billing_cadence_id(pricing_plan_subscription_id) + store_next_billing_date(billing_cadence_id) StripeV2Client.request( :post, - "/v1/subscriptions/#{subscription_id}", - params, + '/v2/billing/intents', + build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id), stripe_api_options ) end - def update_account_status(subscription_data) - # Extract period end from subscription data - current_period_end = extract_attribute(subscription_data, :current_period_end) - period_end_time = current_period_end ? Time.zone.at(current_period_end).iso8601 : nil + def fetch_subscription_id + custom_attribute('stripe_subscription_id').tap do |id| + raise StandardError, 'No pricing plan subscription ID found' if id.blank? + end + end + def fetch_billing_cadence_id(subscription_id) + subscription = retrieve_pricing_plan_subscription(subscription_id) + extract_attribute(subscription, :billing_cadence).tap do |cadence_id| + raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank? + end + end + + def store_next_billing_date(cadence_id) + cadence = retrieve_billing_cadence(cadence_id) + @next_billing_date = extract_attribute(cadence, :next_billing_date) + end + + def build_deactivate_params(subscription_id, cadence_id) + { + cadence: cadence_id, + currency: 'usd', + actions: [{ + type: 'deactivate', + deactivate: { + type: 'pricing_plan_subscription_details', + pricing_plan_subscription_details: { pricing_plan_subscription: subscription_id } + } + }] + } + end + + def reserve_billing_intent(billing_intent) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{billing_intent.id}/reserve", + {}, + stripe_api_options + ) + end + + def commit_billing_intent(billing_intent) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{billing_intent.id}/commit", + {}, + stripe_api_options + ) + end + + def retrieve_pricing_plan_subscription(subscription_id) + StripeV2Client.request( + :get, + "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", + {}, + stripe_api_options + ) + end + + def retrieve_billing_cadence(cadence_id) + StripeV2Client.request( + :get, + "/v2/billing/cadences/#{cadence_id}", + {}, + stripe_api_options + ) + end + + def update_account_status(_billing_intent) # Mark subscription as cancelling (will be cancelled at period end) + # Store next_billing_date so the UI can show when the subscription ends update_custom_attributes({ 'subscription_status' => 'cancel_at_period_end', 'subscription_cancelled_at' => Time.current.iso8601, - 'subscription_period_end' => period_end_time + 'subscription_ends_at' => @next_billing_date }) end - def build_success_response(subscription_data) - current_period_end = extract_attribute(subscription_data, :current_period_end) - period_end_time = current_period_end ? Time.zone.at(current_period_end) : nil - + def build_success_response(_billing_intent) { success: true, cancel_at_period_end: true, - period_end: period_end_time, - message: 'Subscription will be cancelled at the end of the current billing period.' + message: 'Subscription cancellation initiated. It will be deactivated at the end of the current billing period.' } end diff --git a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb new file mode 100644 index 000000000..a2835ff2a --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb @@ -0,0 +1,197 @@ +class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + + # Change customer's pricing plan using Stripe's V2 Billing Intent API + # Creates a modify billing intent to migrate to a new pricing plan + # + # @param new_pricing_plan_id [String] The new Stripe pricing plan ID + # @param quantity [Integer] The quantity for the new plan + # @return [Hash] { success:, message: } + # + def change_plan(new_pricing_plan_id:, quantity: 1) + return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? + return { success: false, message: 'No active subscription' } unless active_subscription? + return { success: false, message: 'Invalid quantity' } unless quantity.positive? + + 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) + commit_billing_intent(billing_intent) + update_account_plan(new_pricing_plan_id, quantity) + success_response(new_pricing_plan_id, quantity) + end + rescue Stripe::StripeError => e + { success: false, message: "Stripe error: #{e.message}" } + end + + private + + def create_change_plan_intent(new_pricing_plan_id, quantity) + subscription_id = fetch_subscription_id + cadence_id = fetch_cadence_from_subscription(subscription_id) + plan_version = fetch_new_plan_version(new_pricing_plan_id) + lookup_key = fetch_plan_lookup_key(new_pricing_plan_id) + component_config = { lookup_key: lookup_key, quantity: quantity } + + StripeV2Client.request( + :post, + '/v2/billing/intents', + build_change_plan_params(subscription_id, cadence_id, new_pricing_plan_id, plan_version, component_config), + stripe_api_options + ) + end + + def fetch_subscription_id + custom_attribute('stripe_subscription_id').tap do |id| + raise StandardError, 'No pricing plan subscription ID found' if id.blank? + end + end + + def fetch_cadence_from_subscription(subscription_id) + subscription = retrieve_pricing_plan_subscription(subscription_id) + extract_attribute(subscription, :billing_cadence).tap do |cadence_id| + raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank? + end + end + + def fetch_new_plan_version(plan_id) + plan = retrieve_pricing_plan(plan_id) + extract_attribute(plan, :latest_version).tap do |version| + raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank? + end + end + + def fetch_plan_lookup_key(plan_id) + Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id).tap do |key| + raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless key + end + end + + def build_change_plan_params(subscription_id, cadence_id, plan_id, plan_version, component_config) + { + cadence: cadence_id, + currency: 'usd', + actions: [{ + type: 'modify', + modify: { + type: 'pricing_plan_subscription_details', + pricing_plan_subscription_details: { + pricing_plan_subscription: subscription_id, + new_pricing_plan: plan_id, + new_pricing_plan_version: plan_version, + component_configurations: [component_config] + } + } + }] + } + end + + def reserve_billing_intent(billing_intent) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{billing_intent.id}/reserve", + {}, + stripe_api_options + ) + 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, + "/v2/billing/intents/#{billing_intent.id}/commit", + {}, + stripe_api_options + ) + end + + def retrieve_pricing_plan_subscription(subscription_id) + StripeV2Client.request( + :get, + "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", + {}, + stripe_api_options + ) + end + + def retrieve_pricing_plan(pricing_plan_id) + StripeV2Client.request( + :get, + "/v2/billing/pricing_plans/#{pricing_plan_id}", + {}, + stripe_api_options + ) + end + + def update_account_plan(new_pricing_plan_id, quantity) + attributes = { + 'pending_stripe_pricing_plan_id' => new_pricing_plan_id, + 'pending_subscription_quantity' => quantity + } + + # Add plan name from catalog + plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id) + attributes['plan_name'] = plan_definition[:display_name] if plan_definition + + update_custom_attributes(attributes) + end + + def extract_plan_name(plan_definition) + # Extract plan name like "Startup", "Business", or "Enterprise" from display_name + plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) } + end + + def success_response(new_pricing_plan_id, quantity) + { + success: true, + pricing_plan_id: new_pricing_plan_id, + quantity: quantity, + message: 'Pricing plan changed successfully' + } + end + + def v2_enabled? + custom_attribute('stripe_billing_version')&.to_i == 2 + end + + def active_subscription? + custom_attribute('subscription_status') == 'active' + end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end + + def extract_attribute(object, key) + object.respond_to?(key) ? object.public_send(key) : object[key.to_s] + end +end 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 4fe25253a..ac459d14f 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -1,29 +1,46 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService + # rubocop:disable Metrics/MethodLength def use_credit(feature: 'ai_captain', amount: 1, metadata: {}) - return { success: true, credits_used: 0, remaining: total_credits } if amount <= 0 - with_locked_account do - return { success: false, message: 'Insufficient credits' } unless total_credits >= amount + # Handle refunds (negative amounts) + if amount.negative? + refund_credits(amount.abs) + log_credit_transaction( + type: 'use', + amount: amount, + credit_type: 'topup', + description: "Refund for #{feature}", + metadata: metadata + ) + { success: true, credits_refunded: amount.abs, remaining: total_credits } + elsif total_credits < amount + # Handle usage (positive amounts) + { success: false, message: 'Insufficient credits' } + else + # Report usage to Stripe + stripe_result = report_usage_to_stripe(amount, feature) - # Report usage to Stripe - stripe_result = report_usage_to_stripe(amount, feature) - return { success: false, message: stripe_result[:message] } unless stripe_result[:success] + if stripe_result[:success] + # Deduct credits locally (monthly first, then topup) + deduct_credits(amount) - # Deduct credits locally (monthly first, then topup) - deduct_credits(amount) + # Log transaction + log_credit_transaction( + type: 'use', + amount: amount, + credit_type: determine_credit_type(amount), + description: "Used for #{feature}", + metadata: metadata + ) - # Log transaction - log_credit_transaction( - type: 'use', - amount: amount, - credit_type: determine_credit_type(amount), - description: "Used for #{feature}", - metadata: metadata - ) - - { success: true, credits_used: amount, remaining: total_credits } + { success: true, credits_used: amount, remaining: total_credits } + else + { success: false, message: stripe_result[:message] } + end + end end end + # rubocop:enable Metrics/MethodLength def sync_monthly_credits(amount, metadata: {}) with_locked_account do @@ -73,6 +90,23 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 } end + def fetch_credit_grants + customer_id = stripe_customer_id + return [] if customer_id.blank? + + response = Stripe::Billing::CreditGrant.list( + { customer: customer_id, limit: 100 }, + stripe_api_options + ) + + response.data.map do |grant| + transform_credit_grant(grant) + end + rescue Stripe::StripeError => e + Rails.logger.error("Failed to fetch credit grants: #{e.message}") + [] + end + def calculate_usage_stats month_start = Time.current.beginning_of_month @@ -98,6 +132,65 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 private + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength + def transform_credit_grant(grant) + # Stripe objects have symbol keys when converted to hash, or access via methods + # Use the grant object directly for most reliable access + category = grant[:category] || grant.category + metadata = grant[:metadata] || grant.metadata || {} + + # Determine credits amount based on grant type + credits = if category == 'paid' && metadata['credits'] + metadata['credits'].to_i + elsif category == 'promotional' + grant_id = grant[:id] || grant.id + fetch_monthly_credit_amount(grant_id) + else + 0 + end + + { + id: grant[:id] || grant.id, + name: grant[:name] || grant.name, + credits: credits, + category: category, + source: metadata['source'] || category, + effective_at: parse_timestamp(grant[:effective_at] || grant.effective_at), + expires_at: parse_timestamp(grant[:expires_at] || grant.expires_at), + voided_at: parse_timestamp(grant[:voided_at] || grant.voided_at), + created_at: parse_timestamp(grant[:created] || grant.created) + } + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength + + def parse_timestamp(timestamp) + return nil unless timestamp + + Time.zone.at(timestamp) + end + + def fetch_monthly_credit_amount(grant_id) + # Try to get from credit transactions with matching grant_id in metadata + transaction = account.credit_transactions + .where(transaction_type: 'grant', credit_type: 'monthly') + .where("metadata->>'grant_id' = ?", grant_id) + .order(created_at: :desc) + .first + + return transaction.amount if transaction + + # Fallback: return the current monthly_credits from account + monthly_credits + end + + def stripe_customer_id + custom_attribute('stripe_customer_id') + end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end + def report_usage_to_stripe(amount, feature) Enterprise::Billing::V2::UsageReporterService.new(account: account).report(amount, feature) end @@ -111,6 +204,11 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end end + def refund_credits(amount) + # Refunds are added to topup credits + update_credits(topup: topup_credits + amount) + end + def determine_credit_type(amount) if monthly_credits >= amount 'monthly' diff --git a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb index 390049eba..21413b9bd 100644 --- a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -25,7 +25,7 @@ module Enterprise::Billing::V2::PlanCatalog base_fee: 39.0, monthly_credits: 50_000, config_key: 'STRIPE_BUSINESS_PLAN_ID', - licensed_item_lookup_key: 'chatwoot_business_license_fee_v2' + licensed_item_lookup_key: 'chatwoot_business_plan_license_fee_v2' }, { key: :enterprise, diff --git a/enterprise/app/services/enterprise/billing/v2/resume_subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/resume_subscription_service.rb new file mode 100644 index 000000000..d3f8468de --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/resume_subscription_service.rb @@ -0,0 +1,149 @@ +class Enterprise::Billing::V2::ResumeSubscriptionService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + + # Resume a cancelled subscription by creating an activate billing intent + # This reactivates the subscription to continue beyond the current period + # + # @return [Hash] { success:, message: } + # + def resume_subscription + return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? + return { success: false, message: 'Subscription is not pending cancellation' } unless cancelling_subscription? + + with_locked_account do + billing_intent = create_activate_intent + reserve_billing_intent(billing_intent) + commit_billing_intent(billing_intent) + update_account_status(billing_intent) + success_response = build_success_response(billing_intent) + success_response + end + rescue Stripe::StripeError => e + { success: false, message: "Stripe error: #{e.message}" } + rescue StandardError => e + { success: false, message: "Resume error: #{e.message}" } + end + + private + + def create_activate_intent + subscription_id = fetch_subscription_id + plan_id = fetch_plan_id + cadence_id = fetch_cadence_from_subscription(subscription_id) + plan_version = fetch_plan_version(plan_id) + + StripeV2Client.request( + :post, + '/v2/billing/intents', + build_activate_params(cadence_id, plan_id, plan_version), + stripe_api_options + ) + end + + def fetch_subscription_id + custom_attribute('stripe_subscription_id').tap do |id| + raise StandardError, 'No pricing plan subscription ID found' if id.blank? + end + end + + def fetch_plan_id + custom_attribute('stripe_pricing_plan_id') + end + + def fetch_cadence_from_subscription(subscription_id) + subscription = retrieve_pricing_plan_subscription(subscription_id) + extract_attribute(subscription, :billing_cadence).tap do |cadence_id| + raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank? + end + end + + def fetch_plan_version(plan_id) + plan = retrieve_pricing_plan(plan_id) + extract_attribute(plan, :latest_version) + end + + def build_activate_params(cadence_id, plan_id, plan_version) + { + cadence: cadence_id, + currency: 'usd', + actions: [{ + type: 'subscribe', + subscribe: { + type: 'pricing_plan_subscription_details', + pricing_plan_subscription_details: { + pricing_plan: plan_id, + pricing_plan_version: plan_version + } + } + }] + } + end + + def reserve_billing_intent(billing_intent) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{billing_intent.id}/reserve", + {}, + stripe_api_options + ) + end + + def commit_billing_intent(billing_intent) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{billing_intent.id}/commit", + {}, + stripe_api_options + ) + end + + def retrieve_pricing_plan_subscription(subscription_id) + StripeV2Client.request( + :get, + "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", + {}, + stripe_api_options + ) + end + + def retrieve_pricing_plan(pricing_plan_id) + StripeV2Client.request( + :get, + "/v2/billing/pricing_plans/#{pricing_plan_id}", + {}, + stripe_api_options + ) + end + + def update_account_status(_billing_intent) + # Mark subscription as active again (remove cancel_at_period_end flag and end date) + update_custom_attributes({ + 'subscription_status' => 'active', + 'subscription_cancelled_at' => nil, + 'subscription_ends_at' => nil + }) + end + + def build_success_response(_billing_intent) + { + success: true, + message: 'Subscription resumed successfully. It will now continue beyond the current billing period.' + } + end + + def v2_enabled? + custom_attribute('stripe_billing_version')&.to_i == 2 + end + + def cancelling_subscription? + custom_attribute('subscription_status') == 'cancel_at_period_end' + end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end + + def extract_attribute(object, key) + object.respond_to?(key) ? object.public_send(key) : object[key.to_s] + end +end 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 6bd595e40..64d684686 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -16,8 +16,6 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil build_success_response(subscription_id, pricing_plan_id, quantity) rescue Stripe::StripeError => e { success: false, message: "Stripe error: #{e.message}" } - rescue StandardError => e - { success: false, message: "Provisioning error: #{e.message}" } end private @@ -43,8 +41,7 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil def extract_pricing_plan_id(subscription) # Extract pricing_plan from the subscription object - pricing_plan = subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan'] - pricing_plan.is_a?(String) ? pricing_plan : pricing_plan&.[]('id') || pricing_plan&.dig(:id) + subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan'] end def extract_subscription_quantity(_subscription) @@ -54,8 +51,11 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{pending_quantity}" return pending_quantity.to_i end - - Rails.logger.warn '[V2 Billing] No pending_subscription_quantity found in custom_attributes, defaulting to quantity=1' + subscribed_quantity = account.custom_attributes['subscribed_quantity'] + if subscribed_quantity.present? && subscribed_quantity.to_i.positive? + Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{subscribed_quantity}" + return subscribed_quantity.to_i + end 1 end diff --git a/enterprise/app/services/enterprise/billing/v2/topup_service.rb b/enterprise/app/services/enterprise/billing/v2/topup_service.rb index 0b2249a99..3fd9730cd 100644 --- a/enterprise/app/services/enterprise/billing/v2/topup_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/topup_service.rb @@ -23,9 +23,23 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank? + # Check if customer has a default payment method + unless customer_has_payment_method? + return { valid: false, success: false, message: 'No payment method found. Please add a payment method before making a purchase.' } + end + { valid: true, topup_definition: topup_definition } end + def customer_has_payment_method? + customer = Stripe::Customer.retrieve(stripe_customer_id, stripe_api_options) + # Check if customer has a default payment method or any payment methods attached + customer.invoice_settings&.default_payment_method.present? || customer.default_source.present? + rescue Stripe::StripeError => e + Rails.logger.error("Failed to check payment method: #{e.message}") + false + end + def process_topup_transaction(credits, amount_cents, currency, amount) invoice = create_topup_invoice(currency) return { success: false, message: 'Failed to create invoice' } unless invoice diff --git a/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb new file mode 100644 index 000000000..6fc7eefe0 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb @@ -0,0 +1,147 @@ +class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + + # Update subscription quantity using Stripe's V2 Billing Intent API + # Creates a modify billing intent to change the subscription quantity + # + # @param quantity [Integer] The new quantity + # @return [Hash] { success:, message: } + # + def update_quantity(quantity:) + return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? + return { success: false, message: 'No active subscription' } unless active_subscription? + return { success: false, message: 'Invalid quantity' } unless quantity.positive? + + with_locked_account do + billing_intent = create_modify_intent(quantity) + reserve_billing_intent(billing_intent) + commit_billing_intent(billing_intent) + update_account_quantity(quantity) + success_response(quantity) + end + rescue Stripe::StripeError => e + { success: false, message: "Stripe error: #{e.message}" } + rescue StandardError => e + { success: false, message: "Update error: #{e.message}" } + end + + private + + def create_modify_intent(quantity) + subscription_id = fetch_subscription_id + subscription = retrieve_pricing_plan_subscription(subscription_id) + cadence_id, plan_id, plan_version = extract_subscription_details(subscription) + lookup_key = fetch_plan_lookup_key(plan_id) + component_config = { lookup_key: lookup_key, quantity: quantity } + + params = build_modify_params(subscription_id, cadence_id, plan_id, plan_version, component_config) + log_modify_intent(params) + + StripeV2Client.request(:post, '/v2/billing/intents', params, stripe_api_options) + end + + def fetch_subscription_id + custom_attribute('stripe_subscription_id').tap do |id| + raise StandardError, 'No pricing plan subscription ID found' if id.blank? + end + end + + def extract_subscription_details(subscription) + cadence = extract_attribute(subscription, :billing_cadence) + plan = extract_attribute(subscription, :pricing_plan) + version = extract_attribute(subscription, :pricing_plan_version) + + raise StandardError, 'No billing cadence found in subscription' if cadence.blank? + raise StandardError, 'No pricing plan found in subscription' if plan.blank? + raise StandardError, 'No pricing plan version found in subscription' if version.blank? + + [cadence, plan, version] + end + + def fetch_plan_lookup_key(plan_id) + Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id).tap do |key| + raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless key + end + end + + def build_modify_params(subscription_id, cadence_id, plan_id, plan_version, component_config) + { + cadence: cadence_id, + currency: 'usd', + actions: [{ + type: 'modify', + modify: { + type: 'pricing_plan_subscription_details', + pricing_plan_subscription_details: { + pricing_plan_subscription: subscription_id, + new_pricing_plan: plan_id, + new_pricing_plan_version: plan_version, + component_configurations: [component_config] + } + } + }] + } + end + + def log_modify_intent(params) + Rails.logger.info "Create modify intent params: #{params.inspect}" + Rails.logger.info "Stripe API options: #{stripe_api_options.inspect}" + end + + def reserve_billing_intent(billing_intent) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{billing_intent.id}/reserve", + {}, + stripe_api_options + ) + end + + def commit_billing_intent(billing_intent) + StripeV2Client.request( + :post, + "/v2/billing/intents/#{billing_intent.id}/commit", + {}, + stripe_api_options + ) + end + + def retrieve_pricing_plan_subscription(subscription_id) + StripeV2Client.request( + :get, + "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", + {}, + stripe_api_options + ) + end + + def update_account_quantity(quantity) + update_custom_attributes({ + 'pending_subscription_quantity' => quantity + }) + end + + def success_response(quantity) + { + success: true, + quantity: quantity, + message: "Subscription quantity updated to #{quantity}" + } + end + + def v2_enabled? + custom_attribute('stripe_billing_version')&.to_i == 2 + end + + def active_subscription? + custom_attribute('subscription_status') == 'active' + end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end + + def extract_attribute(object, key) + object.respond_to?(key) ? object.public_send(key) : object[key.to_s] + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb index 299a8d0b7..ed2ec2802 100644 --- a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb @@ -16,12 +16,12 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B private def valid_configuration? - custom_attribute('stripe_customer_id').present? && meter_event_name.present? + custom_attribute('stripe_customer_id').present? end def meter_event_params(credits_used) { - event_name: meter_event_name, + event_name: 'chatwoot.usage', payload: { value: credits_used.to_s, stripe_customer_id: custom_attribute('stripe_customer_id') @@ -30,15 +30,6 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B } end - def meter_event_name - # Use shared meter event name from ENV/InstallationConfig (preferred) - # Falls back to account-specific event name for backward compatibility - shared_event_name = InstallationConfig.find_by(name: 'STRIPE_METER_EVENT_NAME')&.value || - ENV.fetch('STRIPE_METER_EVENT_NAME', nil) - - shared_event_name || custom_attribute('stripe_meter_event_name') - end - def stripe_api_options { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } end