From f31d9a3e76b21bb3644f3775fb80e846383e2890 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 29 Oct 2025 20:29:09 +0530 Subject: [PATCH 1/3] store next billing dates --- .../api/v1/models/_account.json.jbuilder | 4 ++++ .../billing/v2/change_plan_service.rb | 18 +++++++++++++- .../v2/subscription_provisioning_service.rb | 4 +++- .../billing/v2/update_subscription_service.rb | 24 +++++++++++++------ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index af9235b4b..ba74e5c94 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -6,6 +6,10 @@ if resource.custom_attributes.present? json.subscribed_quantity resource.custom_attributes['subscribed_quantity'] json.subscription_status resource.custom_attributes['subscription_status'] json.subscription_ends_on resource.custom_attributes['subscription_ends_on'] + json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present? + json.pending_stripe_pricing_plan_id resource.custom_attributes['pending_stripe_pricing_plan_id'] if resource.custom_attributes['pending_stripe_pricing_plan_id'].present? + json.pending_subscription_quantity resource.custom_attributes['pending_subscription_quantity'] if resource.custom_attributes['pending_subscription_quantity'].present? + json.next_billing_date resource.custom_attributes['next_billing_date'] if resource.custom_attributes['next_billing_date'].present? json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present? json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present? json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present? 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 e300b83e7..8e3ff74fe 100644 --- a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb @@ -27,6 +27,7 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base def create_change_plan_intent(new_pricing_plan_id, quantity) subscription_id = fetch_subscription_id cadence_id = fetch_cadence_from_subscription(subscription_id) + store_next_billing_date(cadence_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 } @@ -120,10 +121,25 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base ) end + def retrieve_billing_cadence(cadence_id) + StripeV2Client.request( + :get, + "/v2/billing/cadences/#{cadence_id}", + {}, + stripe_api_options + ) + 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 update_account_plan(new_pricing_plan_id, quantity) attributes = { 'pending_stripe_pricing_plan_id' => new_pricing_plan_id, - 'pending_subscription_quantity' => quantity + 'pending_subscription_quantity' => quantity, + 'next_billing_date' => @next_billing_date } # Add plan name from catalog 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 741ad3b99..66c76262d 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -120,7 +120,9 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil 'subscription_status' => 'active', 'pending_subscription_quantity' => nil, 'pending_subscription_pricing_plan' => nil, - 'billing_cadence' => billing_cadence + 'billing_cadence' => billing_cadence, + 'next_billing_date' => nil, + 'pending_stripe_pricing_plan_id' => nil } attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present? diff --git a/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb index a4f10b127..0f6328fe5 100644 --- a/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb @@ -29,11 +29,11 @@ class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing:: subscription_id = fetch_subscription_id subscription = retrieve_pricing_plan_subscription(subscription_id) cadence_id, plan_id, plan_version = extract_subscription_details(subscription) + store_next_billing_date(cadence_id) 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 @@ -81,11 +81,6 @@ class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing:: } 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, @@ -113,9 +108,24 @@ class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing:: ) end + def retrieve_billing_cadence(cadence_id) + StripeV2Client.request( + :get, + "/v2/billing/cadences/#{cadence_id}", + {}, + stripe_api_options + ) + 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 update_account_quantity(quantity) update_custom_attributes({ - 'pending_subscription_quantity' => quantity + 'pending_subscription_quantity' => quantity, + 'next_billing_date' => @next_billing_date }) end From 94985bbc77c1abfc861b8d6a1d8ce0f5e1ed1947 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 29 Oct 2025 21:35:08 +0530 Subject: [PATCH 2/3] remove update subscription flow overall --- app/policies/account_policy.rb | 4 - config/routes.rb | 1 - .../api/v1/accounts/concerns/billing_v2.rb | 16 -- .../billing/v2/update_subscription_service.rb | 147 ------------------ 4 files changed, 168 deletions(-) delete mode 100644 enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index ee5e2b37f..212af6c34 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -59,10 +59,6 @@ class AccountPolicy < ApplicationPolicy @account_user.administrator? end - def update_subscription_quantity? - @account_user.administrator? - end - def change_pricing_plan? @account_user.administrator? end diff --git a/config/routes.rb b/config/routes.rb index b8dc61d1b..28826d84c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -439,7 +439,6 @@ Rails.application.routes.draw do post :v2_topup post :v2_subscribe post :cancel_subscription - post :update_subscription_quantity post :change_pricing_plan end end 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 6fa71998a..97c6c22cc 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 @@ -77,22 +77,6 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2 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( diff --git a/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb deleted file mode 100644 index 0f6328fe5..000000000 --- a/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb +++ /dev/null @@ -1,147 +0,0 @@ -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: '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) - store_next_billing_date(cadence_id) - 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) - - 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 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 store_next_billing_date(cadence_id) - cadence = retrieve_billing_cadence(cadence_id) - @next_billing_date = extract_attribute(cadence, :next_billing_date) - end - - def update_account_quantity(quantity) - update_custom_attributes({ - 'pending_subscription_quantity' => quantity, - 'next_billing_date' => @next_billing_date - }) - end - - def success_response(quantity) - { - success: true, - quantity: quantity, - message: "Subscription quantity updated to #{quantity}" - } - 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 From ce5a859a4fad2cbf688cea3e5dfcdd327908e9c0 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Thu, 30 Oct 2025 15:51:51 +0530 Subject: [PATCH 3/3] create and pay an invoice on subscription change --- .../concerns/plan_provisioning_helper.rb | 44 +++ .../concerns/proration_line_item_builder.rb | 103 ++++++++ .../concerns/stripe_v2_client_helper.rb | 120 +++++++++ .../billing/handle_stripe_event_service.rb | 5 +- .../billing/v2/cancel_subscription_service.rb | 52 +--- .../billing/v2/change_plan_service.rb | 250 ++++++++++-------- .../billing/v2/checkout_session_service.rb | 17 +- .../billing/v2/invoice_payment_service.rb | 111 ++++++++ .../v2/pricing_plan_component_builder.rb | 91 ++----- .../billing/v2/pricing_plan_service.rb | 30 +-- .../billing/v2/proration_calculator.rb | 99 +++++++ .../v2/subscription_provisioning_service.rb | 14 +- .../enterprise/billing/v2/topup_service.rb | 114 +++----- .../billing/v2/webhook_handler_service.rb | 20 +- 14 files changed, 701 insertions(+), 369 deletions(-) create mode 100644 enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb create mode 100644 enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb create mode 100644 enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/proration_calculator.rb diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb new file mode 100644 index 000000000..4f53a899c --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb @@ -0,0 +1,44 @@ +module Enterprise::Billing::Concerns::PlanProvisioningHelper + extend ActiveSupport::Concern + + private + + def provision_new_plan(new_pricing_plan_id) + 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 + + 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) + plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) } + end + + def update_account_plan(new_pricing_plan_id, quantity, next_billing_date) + attributes = { + 'stripe_pricing_plan_id' => new_pricing_plan_id, + 'pending_stripe_pricing_plan_id' => nil, + 'pending_subscription_quantity' => nil, + 'subscribed_quantity' => quantity, + 'next_billing_date' => next_billing_date + } + + 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 +end diff --git a/enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb b/enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb new file mode 100644 index 000000000..fa04dfeb8 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb @@ -0,0 +1,103 @@ +module Enterprise::Billing::Concerns::ProrationLineItemBuilder + extend ActiveSupport::Concern + + private + + def build_proration_line_items(context, proration_data) + return build_seat_change_line_items(context, proration_data) if seat_only_change?(context) + + build_plan_change_line_items(context, proration_data) + end + + def seat_only_change?(context) + !context[:plan_changed] && context[:seats_changed] + end + + def build_seat_change_line_items(context, proration_data) + [{ + amount: (proration_data[:net_amount] * 100).to_i, + description: build_seat_change_description(context), + metadata: build_seat_change_metadata(context, proration_data) + }] + end + + def build_plan_change_line_items(context, proration_data) + line_items = [] + old_plan_name = plan_display_name(context[:old_plan_id]) + new_plan_name = plan_display_name(context[:target_plan_id]) + + line_items << build_credit_line_item(context, proration_data, old_plan_name) if proration_data[:credit_amount].positive? + line_items << build_charge_line_item(context, proration_data, new_plan_name) if proration_data[:charge_amount].positive? + + line_items + end + + def plan_display_name(plan_id) + Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan' + end + + def build_credit_line_item(context, proration_data, old_plan_name) + { + amount: -(proration_data[:credit_amount] * 100).to_i, + description: credit_description(old_plan_name, context[:old_quantity]), + metadata: credit_metadata(old_plan_name, context[:old_quantity], proration_data[:days_remaining]) + } + end + + def build_charge_line_item(context, proration_data, new_plan_name) + { + amount: (proration_data[:charge_amount] * 100).to_i, + description: charge_description(new_plan_name, context[:target_quantity]), + metadata: charge_metadata(new_plan_name, context[:target_quantity], proration_data[:days_remaining]) + } + end + + def credit_description(plan_name, quantity) + "Credit for unused time on #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})" + end + + def charge_description(plan_name, quantity) + "Prorated charge for #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})" + end + + def credit_metadata(plan_name, quantity, days_remaining) + { + type: 'proration_credit', + old_plan: plan_name, + old_quantity: quantity, + days_remaining: days_remaining, + billing_version: 'v2' + } + end + + def charge_metadata(plan_name, quantity, days_remaining) + { + type: 'proration_charge', + new_plan: plan_name, + new_quantity: quantity, + days_remaining: days_remaining, + billing_version: 'v2' + } + end + + def build_seat_change_description(context) + plan_name = plan_display_name(context[:target_plan_id]) + change_type = context[:target_quantity] > context[:old_quantity] ? 'increase' : 'decrease' + quantity_diff = (context[:target_quantity] - context[:old_quantity]).abs + + "Seat #{change_type} for #{plan_name}: #{context[:old_quantity]} → #{context[:target_quantity]} " \ + "seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})" + end + + def build_seat_change_metadata(context, proration_data) + { + type: 'seat_change', + plan_name: plan_display_name(context[:target_plan_id]), + old_quantity: context[:old_quantity], + new_quantity: context[:target_quantity], + quantity_change: context[:target_quantity] - context[:old_quantity], + days_remaining: proration_data[:days_remaining], + billing_version: 'v2' + } + end +end diff --git a/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb new file mode 100644 index 000000000..e7f2e39d9 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb @@ -0,0 +1,120 @@ +module Enterprise::Billing::Concerns::StripeV2ClientHelper + extend ActiveSupport::Concern + + private + + # Generic Stripe V2 API request wrapper + def stripe_v2_request(method, path, params = {}, api_version: nil) + StripeV2Client.request(method, path, params, stripe_api_options(api_version)) + end + + # Pricing Plan Subscriptions + def retrieve_pricing_plan_subscription(subscription_id) + stripe_v2_request(:get, "/v2/billing/pricing_plan_subscriptions/#{subscription_id}") + end + + # Pricing Plans + def retrieve_pricing_plan(pricing_plan_id) + stripe_v2_request(:get, "/v2/billing/pricing_plans/#{pricing_plan_id}") + end + + def create_pricing_plan(params) + stripe_v2_request(:post, '/v2/billing/pricing_plans', params) + end + + def update_pricing_plan(plan_id, params) + stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}", params) + end + + # Billing Cadences + def retrieve_billing_cadence(cadence_id) + stripe_v2_request(:get, "/v2/billing/cadences/#{cadence_id}") + end + + # Billing Intents + def create_billing_intent(params) + stripe_v2_request(:post, '/v2/billing/intents', params) + end + + def reserve_billing_intent(billing_intent) + stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/reserve") + end + + def commit_billing_intent(billing_intent) + stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/commit") + end + + # Custom Pricing Units + def create_custom_pricing_unit(params) + stripe_v2_request(:post, '/v2/billing/custom_pricing_units', params) + end + + # Licensed Items + def create_licensed_item(params) + stripe_v2_request(:post, '/v2/billing/licensed_items', params) + end + + # License Fees + def create_license_fee(params) + stripe_v2_request(:post, '/v2/billing/license_fees', params) + end + + # Service Actions + def create_service_action(params) + stripe_v2_request(:post, '/v2/billing/service_actions', params) + end + + # Rate Cards + def create_rate_card(params) + stripe_v2_request(:post, '/v2/billing/rate_cards', params) + end + + def add_rate_to_card(card_id, params) + stripe_v2_request(:post, "/v2/billing/rate_cards/#{card_id}/rates", params) + end + + # Metered Items + def create_metered_item(params) + stripe_v2_request(:post, '/v2/billing/metered_items', params) + end + + # Meters (V1 API but used with V2) + def create_meter(params) + stripe_v2_request(:post, '/v1/billing/meters', params) + end + + # Pricing Plan Components + def add_pricing_plan_component(plan_id, params) + stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}/components", params) + end + + # Checkout Sessions (V1 API but used with V2 plans) + def create_checkout_session(params, api_version: nil) + stripe_v2_request(:post, '/v1/checkout/sessions', params, api_version: api_version) + end + + # Credit Grants (V1 API but used with V2) + def retrieve_credit_grant(grant_id) + stripe_v2_request(:get, "/v1/billing/credit_grants/#{grant_id}") + end + + # API Options with support for custom versions + def stripe_api_options(custom_version = nil) + { + api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), + stripe_version: custom_version || default_stripe_version + } + end + + def default_stripe_version + '2025-08-27.preview' + end + + def checkout_stripe_version + '2025-08-27.preview;checkout_product_catalog_preview=v1' + 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/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index bb1df4f54..820b2a001 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -1,5 +1,6 @@ class Enterprise::Billing::HandleStripeEventService include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::StripeV2ClientHelper CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze @@ -124,10 +125,6 @@ class Enterprise::Billing::HandleStripeEventService grant_object.respond_to?(:id) ? grant_object.id : grant_object['id'] end - def retrieve_credit_grant(grant_id) - StripeV2Client.request(:get, "/v1/billing/credit_grants/#{grant_id}") - end - def extract_credit_amount(grant) # First, try to get credits from metadata metadata = extract_attribute(grant, :metadata) 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 34fa145ae..9c4e163ae 100644 --- a/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb @@ -1,5 +1,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::StripeV2ClientHelper # Cancel subscription using Stripe's V2 Billing Intent API # Creates a deactivate billing intent for the pricing plan subscription @@ -29,11 +30,8 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: billing_cadence_id = fetch_billing_cadence_id(pricing_plan_subscription_id) store_next_billing_date(billing_cadence_id) - StripeV2Client.request( - :post, - '/v2/billing/intents', - build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id), - stripe_api_options + create_billing_intent( + build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id) ) end @@ -70,42 +68,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: } 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 @@ -123,12 +85,4 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: message: 'Subscription cancellation initiated. It will be deactivated at the end of the current billing period.' } 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/change_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb index 8e3ff74fe..9d97771a7 100644 --- a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb @@ -1,22 +1,23 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::BaseService include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::ProrationLineItemBuilder + include Enterprise::Billing::Concerns::StripeV2ClientHelper + include Enterprise::Billing::Concerns::PlanProvisioningHelper - # Change customer's pricing plan using Stripe's V2 Billing Intent API - # Creates a modify billing intent to migrate to a new pricing plan + # Change customer's pricing plan and/or seat quantity using invoice line items for proration + # Instantly applies the change and creates pending invoice line items + # for prorated charges that will be added to the next invoice # - # @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: } + # @param new_pricing_plan_id [String, nil] The new Stripe pricing plan ID (nil to keep current plan) + # @param quantity [Integer] The seat quantity for the plan + # @return [Hash] { success:, message:, proration:, line_items: } # - def change_plan(new_pricing_plan_id:, quantity: 1) - return { success: false, message: 'Invalid quantity' } unless quantity.positive? + def change_plan(new_pricing_plan_id: nil, quantity: nil) + validation_error = validate_parameters(new_pricing_plan_id, quantity) + return validation_error if validation_error with_locked_account do - billing_intent = create_change_plan_intent(new_pricing_plan_id, quantity) - 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) + perform_subscription_change(new_pricing_plan_id, quantity) end rescue Stripe::StripeError => e { success: false, message: "Stripe error: #{e.message}" } @@ -24,20 +25,71 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base private - def create_change_plan_intent(new_pricing_plan_id, quantity) - subscription_id = fetch_subscription_id - cadence_id = fetch_cadence_from_subscription(subscription_id) - store_next_billing_date(cadence_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 } + def validate_parameters(new_pricing_plan_id, quantity) + return { success: false, message: 'Must specify either new_pricing_plan_id or quantity' } if new_pricing_plan_id.nil? && quantity.nil? + return { success: false, message: 'Invalid quantity' } if quantity && !quantity.positive? - 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 + # Validate customer has a default payment method using common service + payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account) + payment_method_validation = payment_service.validate_payment_method + return payment_method_validation unless payment_method_validation.nil? + + nil + end + + def perform_subscription_change(new_pricing_plan_id, quantity) + change_context = build_change_context(new_pricing_plan_id, quantity) + return no_change_response(change_context) unless change_required?(change_context) + + execute_change(change_context) + end + + def build_change_context(new_pricing_plan_id, quantity) + old_plan_id = custom_attribute('stripe_pricing_plan_id') + old_quantity = custom_attribute('subscribed_quantity').to_i + target_plan_id = new_pricing_plan_id || old_plan_id + target_quantity = quantity || old_quantity + + { + old_plan_id: old_plan_id, + old_quantity: old_quantity, + target_plan_id: target_plan_id, + target_quantity: target_quantity, + plan_changed: old_plan_id != target_plan_id, + seats_changed: old_quantity != target_quantity + } + end + + def change_required?(context) + context[:plan_changed] || context[:seats_changed] + end + + def no_change_response(context) + plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan' + { success: false, message: "Subscription already has plan #{plan_name} with #{context[:target_quantity]} seat(s)" } + end + + def execute_change(context) + # Create billing intent to update the Stripe subscription + billing_intent = create_change_plan_intent(context[:target_plan_id], context[:target_quantity]) + reserve_billing_intent(billing_intent) + commit_billing_intent(billing_intent) + + next_billing_date = custom_attribute('next_billing_date') + proration_data = calculate_proration( + old_plan_id: context[:old_plan_id], + new_plan_id: context[:target_plan_id], + old_quantity: context[:old_quantity], + new_quantity: context[:target_quantity], + next_billing_date: next_billing_date ) + line_items = build_proration_line_items(context, proration_data) + invoice_result = create_and_charge_invoice(line_items) + + update_account_plan(context[:target_plan_id], context[:target_quantity], next_billing_date) + provision_new_plan(context[:target_plan_id]) if context[:plan_changed] + + success_response(context, proration_data, line_items, invoice_result) end def fetch_subscription_id @@ -53,17 +105,17 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base 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 create_change_plan_intent(new_pricing_plan_id, quantity) + subscription_id = fetch_subscription_id + cadence_id = fetch_cadence_from_subscription(subscription_id) + store_next_billing_date(cadence_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 } - 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 + create_billing_intent( + build_change_plan_params(subscription_id, cadence_id, new_pricing_plan_id, plan_version, component_config) + ) end def build_change_plan_params(subscription_id, cadence_id, plan_id, plan_version, component_config) @@ -85,89 +137,81 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base } 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 retrieve_billing_cadence(cadence_id) - StripeV2Client.request( - :get, - "/v2/billing/cadences/#{cadence_id}", - {}, - stripe_api_options - ) - end - def store_next_billing_date(cadence_id) cadence = retrieve_billing_cadence(cadence_id) @next_billing_date = extract_attribute(cadence, :next_billing_date) + update_custom_attributes({ 'next_billing_date' => @next_billing_date }) end - def update_account_plan(new_pricing_plan_id, quantity) - attributes = { - 'pending_stripe_pricing_plan_id' => new_pricing_plan_id, - 'pending_subscription_quantity' => quantity, - 'next_billing_date' => @next_billing_date - } - - # 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) + 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 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) } + 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 success_response(new_pricing_plan_id, quantity) + def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:) + old_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(old_plan_id)&.dig(:base_fee) || 0.0 + new_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(new_plan_id)&.dig(:base_fee) || 0.0 + + Enterprise::Billing::V2::ProrationCalculator.calculate( + old_plan_price: old_plan_price, + new_plan_price: new_plan_price, + old_quantity: old_quantity, + new_quantity: new_quantity, + next_billing_date: next_billing_date + ) + end + + def create_and_charge_invoice(line_items) + # Return success with no invoice if no line items (negligible proration) + return { success: true, amount: 0.0, message: 'No charges due to negligible proration' } if line_items.empty? + + # Use common invoice payment service + payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account) + payment_service.create_and_pay_invoice( + line_items: line_items, + description: 'Proration charges for plan/seat changes', + metadata: { + account_id: account.id.to_s, + type: 'proration' + } + ) + end + + def success_response(context, proration_data, line_items, invoice_result) { success: true, - pricing_plan_id: new_pricing_plan_id, - quantity: quantity, - message: 'Pricing plan changed successfully' + pricing_plan_id: context[:target_plan_id], + quantity: context[:target_quantity], + old_pricing_plan_id: context[:old_plan_id], + old_quantity: context[:old_quantity], + plan_changed: context[:plan_changed], + seats_changed: context[:seats_changed], + proration: proration_data, + line_items: line_items, + invoice: invoice_result, + total_proration_amount: proration_data[:net_amount], + message: build_change_message(context) } end - def stripe_api_options - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - end + def build_change_message(context) + plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan' + base_message = if context[:plan_changed] && context[:seats_changed] + "Plan changed to #{plan_name} and seats updated to #{context[:target_quantity]}" + elsif context[:plan_changed] + "Plan changed to #{plan_name}" + else + "Seats updated from #{context[:old_quantity]} to #{context[:target_quantity]}" + end - def extract_attribute(object, key) - object.respond_to?(key) ? object.public_send(key) : object[key.to_s] + "#{base_message} - billing intent committed and invoice line items created" end end diff --git a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb index 739285721..45708616c 100644 --- a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb @@ -5,6 +5,7 @@ # class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::StripeV2ClientHelper # Create a subscription checkout session # @@ -53,14 +54,7 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: # def create_checkout_session customer_id = custom_attribute('stripe_customer_id') - - session = StripeV2Client.request( - :post, - '/v1/checkout/sessions', - checkout_session_params(customer_id), - stripe_api_options - ) - + session = super(checkout_session_params(customer_id), api_version: checkout_stripe_version) build_success_response(session) end @@ -126,11 +120,4 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: redirect_url: session_url } end - - def stripe_api_options - { - api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), - stripe_version: '2025-08-27.preview;checkout_product_catalog_preview=v1' - } - end end diff --git a/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb b/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb new file mode 100644 index 000000000..0c380904c --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb @@ -0,0 +1,111 @@ +class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::BaseService + # Common service for creating invoices with line items and charging immediately + # Used by both TopupService and ChangePlanService + # + # @param line_items [Array] Array of line items: [{ amount:, description:, metadata: }] + # @param description [String] Description for the invoice + # @param currency [String] Currency code (default: 'usd') + # @param metadata [Hash] Metadata for the invoice + # @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: } + + # Validate that customer has a default payment method + # @return [Hash, nil] Returns error hash if validation fails, nil if success + def validate_payment_method + customer_id = custom_attribute('stripe_customer_id') + return { success: false, message: 'No Stripe customer ID found' } if customer_id.blank? + + customer = Stripe::Customer.retrieve(customer_id, stripe_api_options) + + if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil? + return { + success: false, + message: 'No default payment method found. Please add a default payment method before making a purchase.' + } + end + + nil + rescue Stripe::StripeError => e + Rails.logger.error("Failed to check payment method: #{e.message}") + { success: false, message: "Error validating payment method: #{e.message}" } + end + + # Create invoice with line items and charge immediately + # + # @param line_items [Array] Line items: [{ amount: (cents), description:, metadata: }] + # @param description [String] Invoice description + # @param currency [String] Currency (default: 'usd') + # @param metadata [Hash] Invoice metadata + # @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: } + def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {}) + customer_id = custom_attribute('stripe_customer_id') + invoice = create_invoice(customer_id, currency, description, metadata) + add_line_items_to_invoice(invoice.id, customer_id, line_items, currency) + finalize_and_pay_invoice(invoice.id) + rescue Stripe::StripeError => e + Rails.logger.error("Error creating invoice: #{e.message}") + { success: false, message: "Error creating invoice: #{e.message}" } + end + + private + + def create_invoice(customer_id, currency, description, metadata) + Stripe::Invoice.create({ + customer: customer_id, + currency: currency, + collection_method: 'charge_automatically', + auto_advance: false, + description: description, + metadata: metadata.stringify_keys + }, stripe_api_options) + end + + def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency) + line_items.each do |item| + Stripe::InvoiceItem.create({ + customer: customer_id, + amount: item[:amount], + currency: currency, + invoice: invoice_id, + description: item[:description], + metadata: (item[:metadata] || {}).stringify_keys + }, stripe_api_options) + end + end + + # Finalize invoice and pay it immediately + # @param invoice_id [String] Stripe invoice ID + # @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: } + def finalize_and_pay_invoice(invoice_id) + # Finalize the invoice + finalized_invoice = Stripe::Invoice.finalize_invoice( + invoice_id, + { auto_advance: false }, + stripe_api_options + ) + + # Pay the invoice immediately if not already paid + if finalized_invoice.status == 'paid' + build_invoice_response(finalized_invoice) + else + paid_invoice = Stripe::Invoice.pay(invoice_id, {}, stripe_api_options) + build_invoice_response(paid_invoice) + end + rescue Stripe::StripeError => e + Rails.logger.error("Error finalizing/paying invoice: #{e.message}") + { success: false, message: "Error processing payment: #{e.message}" } + end + + def build_invoice_response(invoice) + { + success: true, + invoice_id: invoice.id, + invoice_url: invoice.hosted_invoice_url, + amount: invoice.total / 100.0, + status: invoice.status + } + end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb index 909b97112..c3a04ac14 100644 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb @@ -1,4 +1,6 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::StripeV2ClientHelper + def add_license_fee_component(plan, config) licensed_item = create_licensed_item( display_name: config[:licensed_item_display_name], @@ -62,39 +64,24 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing private def create_licensed_item(display_name:, lookup_key:, unit_label:) - StripeV2Client.request( - :post, - '/v2/billing/licensed_items', - { display_name: display_name, lookup_key: lookup_key, unit_label: unit_label }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + super({ display_name: display_name, lookup_key: lookup_key, unit_label: unit_label }) end def create_license_fee(display_name:, unit_amount:, licensed_item_id:, lookup_key:) - StripeV2Client.request( - :post, - '/v2/billing/license_fees', - { - display_name: display_name, - currency: 'usd', - service_interval: 'month', - service_interval_count: 1, - tax_behavior: 'exclusive', - unit_amount: unit_amount.to_s, - licensed_item: licensed_item_id, - lookup_key: lookup_key - }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + super({ + display_name: display_name, + currency: 'usd', + service_interval: 'month', + service_interval_count: 1, + tax_behavior: 'exclusive', + unit_amount: unit_amount.to_s, + licensed_item: licensed_item_id, + lookup_key: lookup_key + }) end def create_service_action(lookup_key:, credit_amount:, cpu_id:) - StripeV2Client.request( - :post, - '/v2/billing/service_actions', - service_action_params(lookup_key, credit_amount, cpu_id), - stripe_api_options - ) + super(service_action_params(lookup_key, credit_amount, cpu_id)) end def service_action_params(lookup_key, credit_amount, cpu_id) @@ -119,44 +106,25 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing } end - def stripe_api_options - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - end - def create_rate_card(display_name:) - StripeV2Client.request( - :post, - '/v2/billing/rate_cards', - { - display_name: display_name, - currency: 'usd', - service_interval: 'month', - service_interval_count: 1, - tax_behavior: 'exclusive' - }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + super({ + display_name: display_name, + currency: 'usd', + service_interval: 'month', + service_interval_count: 1, + tax_behavior: 'exclusive' + }) end def create_metered_item(display_name:, lookup_key:, meter_id:) - StripeV2Client.request( - :post, - '/v2/billing/metered_items', - { display_name: display_name, lookup_key: lookup_key, meter: meter_id }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + super({ display_name: display_name, lookup_key: lookup_key, meter: meter_id }) end def add_rate(card_id:, item_id:, cpu_id:, value:) - StripeV2Client.request( - :post, - "/v2/billing/rate_cards/#{card_id}/rates", - { - metered_item: item_id, - custom_pricing_unit_amount: { id: cpu_id, value: value.to_s } - }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + add_rate_to_card(card_id, { + metered_item: item_id, + custom_pricing_unit_amount: { id: cpu_id, value: value.to_s } + }) end def add_component(plan_id:, type:, data:, lookup_key:) @@ -169,11 +137,6 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing { type: 'rate_card', rate_card: data, lookup_key: lookup_key } end - StripeV2Client.request( - :post, - "/v2/billing/pricing_plans/#{plan_id}/components", - params, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + add_pricing_plan_component(plan_id, params) end end diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb index c3a29819d..56d6c73d4 100644 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb @@ -1,11 +1,8 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::StripeV2ClientHelper + def create_custom_pricing_unit(display_name:, lookup_key:) - StripeV2Client.request( - :post, - '/v2/billing/custom_pricing_units', - { display_name: display_name, lookup_key: lookup_key }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + super({ display_name: display_name, lookup_key: lookup_key }) end def create_meter(display_name:, event_name:) @@ -17,21 +14,11 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas 'customer_mapping[event_payload_key]' => 'stripe_customer_id', 'value_settings[event_payload_key]' => 'value' } - StripeV2Client.request( - :post, - '/v1/billing/meters', - params, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + super(params) end def create_pricing_plan(display_name:, lookup_key:, currency: 'usd', tax_behavior: 'exclusive') - StripeV2Client.request( - :post, - '/v2/billing/pricing_plans', - { display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + super({ display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key }) end def create_complete_pricing_plan(config) @@ -103,12 +90,7 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas end def make_plan_version_live(plan_id) - StripeV2Client.request( - :post, - "/v2/billing/pricing_plans/#{plan_id}", - { live_version: 'latest' }, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) + update_pricing_plan(plan_id, { live_version: 'latest' }) end def build_plan_result(plan, cpu, meter) diff --git a/enterprise/app/services/enterprise/billing/v2/proration_calculator.rb b/enterprise/app/services/enterprise/billing/v2/proration_calculator.rb new file mode 100644 index 000000000..dacd2cc63 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/proration_calculator.rb @@ -0,0 +1,99 @@ +# rubocop:disable Style/ClassAndModuleChildren +module Enterprise + module Billing + module V2 + class ProrationCalculator + # Calculate prorated amounts for subscription changes + # + # @param old_plan_price [Float] Price per unit of the old plan + # @param new_plan_price [Float] Price per unit of the new plan + # @param old_quantity [Integer] Old quantity/seats + # @param new_quantity [Integer] New quantity/seats + # @param next_billing_date [String/Time] Next billing date (ISO 8601) + # @return [Hash] { credit_amount:, charge_amount:, net_amount:, days_remaining:, total_days: } + # + def self.calculate(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:) + new( + old_plan_price: old_plan_price, + new_plan_price: new_plan_price, + old_quantity: old_quantity, + new_quantity: new_quantity, + next_billing_date: next_billing_date + ).calculate + end + + def initialize(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:) + @old_plan_price = old_plan_price.to_f + @new_plan_price = new_plan_price.to_f + @old_quantity = old_quantity.to_i + @new_quantity = new_quantity.to_i + @next_billing_date = parse_date(next_billing_date) + @current_date = Time.zone.now + end + + def calculate + { + credit_amount: credit_amount, + charge_amount: charge_amount, + net_amount: net_amount, + days_remaining: days_remaining, + total_days: total_days, + proration_factor: proration_factor + } + end + + private + + def parse_date(date) + return date if date.is_a?(Time) || date.is_a?(DateTime) + + Time.zone.parse(date.to_s) + rescue StandardError + raise ArgumentError, "Invalid next_billing_date format: #{date}" + end + + # Credit from unused time on old plan + def credit_amount + @credit_amount ||= (@old_plan_price * @old_quantity * proration_factor).round(2) + end + + # Charge for new plan for remaining time + def charge_amount + @charge_amount ||= (@new_plan_price * @new_quantity * proration_factor).round(2) + end + + # Net amount to charge (positive) or credit (negative) + def net_amount + @net_amount ||= (charge_amount - credit_amount).round(2) + end + + # Number of days remaining in current billing period + def days_remaining + @days_remaining ||= (@next_billing_date.to_date - @current_date.to_date).to_i + end + + # Total days in the actual billing period (calculate from current cycle) + # This ensures proration factor never exceeds 1.0 + def total_days + @total_days ||= begin + # Calculate the total days in this billing cycle + # Assume billing started one month ago from next_billing_date + billing_start = @next_billing_date - 1.month + ((@next_billing_date.to_date - billing_start.to_date).to_i) + end + end + + # Fraction of billing period remaining + # This value should always be between 0.0 and 1.0 + def proration_factor + @proration_factor ||= begin + factor = days_remaining.to_f / total_days + # Ensure factor never exceeds 1.0 + [factor, 1.0].min.round(4) + end + end + end + end + end +end +# rubocop:enable Style/ClassAndModuleChildren 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 66c76262d..dd6e418eb 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::StripeV2ClientHelper def provision(subscription_id:) # Retrieve pricing plan subscription details from Stripe V2 API @@ -77,15 +78,6 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil reset_captain_usage end - def retrieve_pricing_plan_subscription(subscription_id) - StripeV2Client.request( - :get, - "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", - {}, - stripe_api_options - ) - end - def extract_pricing_plan_id(subscription) # Extract pricing_plan from the subscription object subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan'] @@ -163,8 +155,4 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil # e.g., "Chatwoot Startup" -> "Startup" plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) } end - - def stripe_api_options - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - end end diff --git a/enterprise/app/services/enterprise/billing/v2/topup_service.rb b/enterprise/app/services/enterprise/billing/v2/topup_service.rb index 5cb829f7d..018b204b9 100644 --- a/enterprise/app/services/enterprise/billing/v2/topup_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/topup_service.rb @@ -27,11 +27,10 @@ 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 default payment method found. Please add a default payment method before making a purchase.' } - end + # Check if customer has a default payment method using common service + payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account) + payment_method_validation = payment_service.validate_payment_method + return payment_method_validation.merge(valid: false) if payment_method_validation { valid: true, topup_definition: topup_definition } end @@ -51,33 +50,40 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi { valid: true } 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 - - invoice_item = create_topup_invoice_item(invoice.id, amount_cents, currency, credits) - return { success: false, message: 'Failed to create invoice item' } unless invoice_item - - finalized_invoice = finalize_topup_invoice(invoice.id) - return { success: false, message: 'Failed to finalize invoice' } unless finalized_invoice - - paid_invoice = pay_invoice(invoice.id) - return { success: false, message: 'Failed to pay invoice' } unless paid_invoice + line_items = build_topup_line_items(credits, amount_cents) + invoice_result = charge_topup_invoice(line_items, currency) + return invoice_result unless invoice_result[:success] credit_grant = create_stripe_credit_grant(amount_cents, currency, credits) return { success: false, message: 'Failed to create credit grant in Stripe' } unless credit_grant - # Credits will be added by webhook when Stripe sends billing.credit_grant.created event - build_success_response(credits, amount, currency, invoice.id, credit_grant['id']) + build_success_response(credits, amount, currency, invoice_result[:invoice_id], credit_grant['id']) + end + + def build_topup_line_items(credits, amount_cents) + [{ + amount: amount_cents, + description: "Credit Topup: #{credits} credits", + metadata: { + account_id: account.id.to_s, + credits: credits.to_s, + topup: 'true' + } + }] + end + + def charge_topup_invoice(line_items, currency) + payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account) + payment_service.create_and_pay_invoice( + line_items: line_items, + description: 'Credit top-up purchase', + currency: currency, + metadata: { + account_id: account.id.to_s, + topup: 'true' + } + ) end def build_success_response(credits, amount, currency, invoice_id, credit_grant_id) @@ -92,60 +98,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi } end - # Create Invoice following Stripe UBB Integration Guide - def create_topup_invoice(currency) - Stripe::Invoice.create( - { - customer: stripe_customer_id, - currency: currency, - collection_method: 'charge_automatically', - auto_advance: false, # We'll finalize it manually - metadata: { - account_id: account.id.to_s, - topup: 'true' - } - }, - stripe_api_options - ) - end - - # Create Invoice Item with topup amount - def create_topup_invoice_item(invoice_id, amount_cents, currency, credits) - Stripe::InvoiceItem.create( - { - customer: stripe_customer_id, - amount: amount_cents, - currency: currency, - invoice: invoice_id, - description: "Credit Topup: #{credits} credits", - metadata: { - account_id: account.id.to_s, - credits: credits.to_s, - topup: 'true' - } - }, - stripe_api_options - ) - end - - # Finalize Invoice for payment - def finalize_topup_invoice(invoice_id) - Stripe::Invoice.finalize_invoice( - invoice_id, - { auto_advance: false }, # We'll pay it explicitly - stripe_api_options - ) - end - - # Pay the invoice explicitly - def pay_invoice(invoice_id) - Stripe::Invoice.pay( - invoice_id, - {}, - stripe_api_options - ) - end - # Create Credit Grant in Stripe using monetary amount (not custom_pricing_unit) # Following Stripe UBB Integration Guide section 8 def create_stripe_credit_grant(amount_cents, currency, credits) 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 32c14a8b1..ee569b217 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -1,4 +1,6 @@ class Enterprise::Billing::V2::WebhookHandlerService + include Enterprise::Billing::Concerns::StripeV2ClientHelper + def perform(event:) @event = event return { success: false, message: 'Event is required' } if @event.blank? @@ -37,20 +39,10 @@ class Enterprise::Billing::V2::WebhookHandlerService end def fetch_customer_id_from_subscription(subscription_id) - subscription = StripeV2Client.request( - :get, - "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", - {}, - stripe_api_options - ) + subscription = retrieve_pricing_plan_subscription(subscription_id) return nil unless subscription&.billing_cadence - cadence = StripeV2Client.request( - :get, - "/v2/billing/cadences/#{subscription.billing_cadence}", - {}, - stripe_api_options - ) + cadence = retrieve_billing_cadence(subscription.billing_cadence) cadence.payer&.customer end @@ -65,8 +57,4 @@ class Enterprise::Billing::V2::WebhookHandlerService .new(account: account) .refresh end - - def stripe_api_options - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - end end