From c853d07f02a1bdb1956cd80fc693fa829f4ad2d9 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Mon, 10 Nov 2025 14:28:18 +0530 Subject: [PATCH] init: base for stripe V2 --- Gemfile | 2 +- Gemfile.lock | 4 +- app/policies/account_policy.rb | 28 ++++ .../api/v1/models/_account.json.jbuilder | 11 ++ config/routes.rb | 8 + .../api/v1/accounts/concerns/billing_v2.rb | 62 ++++++++ .../enterprise/api/v1/accounts_controller.rb | 2 + .../enterprise/webhooks/stripe_controller.rb | 32 +++- .../account/plan_usage_and_limits.rb | 26 +-- .../billing/concerns/plan_feature_manager.rb | 90 +++++++++++ .../concerns/plan_provisioning_helper.rb | 42 +++++ .../concerns/stripe_v2_client_helper.rb | 65 ++++++++ .../billing/create_stripe_customer_service.rb | 54 ++++--- .../billing/handle_stripe_event_service.rb | 148 +++++++----------- .../enterprise/billing/v2/base_service.rb | 90 +++++++++++ .../billing/v2/checkout_session_service.rb | 85 ++++++++++ .../billing/v2/credit_management_service.rb | 84 ++++++++++ .../enterprise/billing/v2/plan_catalog.rb | 112 +++++++++++++ .../v2/subscription_provisioning_service.rb | 116 ++++++++++++++ .../enterprise/billing/v2/topup_catalog.rb | 34 ++++ .../billing/v2/webhook_handler_service.rb | 60 +++++++ 21 files changed, 1025 insertions(+), 130 deletions(-) create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb create mode 100644 enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb create mode 100644 enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb create mode 100644 enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/base_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/credit_management_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/plan_catalog.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/topup_catalog.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb diff --git a/Gemfile b/Gemfile index abbd3332f..8413739f2 100644 --- a/Gemfile +++ b/Gemfile @@ -159,7 +159,7 @@ gem 'working_hours' gem 'pg_search' # Subscriptions, Billing -gem 'stripe' +gem 'stripe', '17.2.0.pre.alpha.2' ## - helper gems --## ## to populate db with sample data diff --git a/Gemfile.lock b/Gemfile.lock index 99e75b33c..9d0c6286b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -902,7 +902,7 @@ GEM squasher (0.7.2) stackprof (0.2.25) statsd-ruby (1.5.0) - stripe (8.5.0) + stripe (17.2.0.pre.alpha.2) telephone_number (1.4.20) test-prof (1.2.1) thor (1.4.0) @@ -1110,7 +1110,7 @@ DEPENDENCIES spring-watcher-listen squasher stackprof - stripe + stripe (= 17.2.0.pre.alpha.2) telephone_number test-prof tidewave diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 61e02ae77..bd8fc7b98 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -30,4 +30,32 @@ class AccountPolicy < ApplicationPolicy def toggle_deletion? @account_user.administrator? end + + def v2_pricing_plans? + @account_user.administrator? + end + + def v2_topup_options? + @account_user.administrator? + end + + def v2_topup? + @account_user.administrator? + end + + def v2_subscribe? + @account_user.administrator? + end + + def cancel_subscription? + @account_user.administrator? + end + + def credit_grants? + @account_user.administrator? + end + + def change_pricing_plan? + @account_user.administrator? + end end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index efeff7db5..463db1cd3 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -6,6 +6,17 @@ 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.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present? + json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present? + if resource.custom_attributes['pending_stripe_pricing_plan_id'].present? + json.pending_stripe_pricing_plan_id resource.custom_attributes['pending_stripe_pricing_plan_id'] + end + if resource.custom_attributes['pending_subscription_quantity'].present? + json.pending_subscription_quantity resource.custom_attributes['pending_subscription_quantity'] + end + json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].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/config/routes.rb b/config/routes.rb index 639c51da7..d886d004a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -431,6 +431,14 @@ Rails.application.routes.draw do post :subscription get :limits post :toggle_deletion + # V2 Billing endpoints + get :credit_grants + get :v2_pricing_plans + get :v2_topup_options + post :v2_topup + post :v2_subscribe + post :cancel_subscription + post :change_pricing_plan end 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 new file mode 100644 index 000000000..dc52958bd --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -0,0 +1,62 @@ +module Enterprise::Api::V1::Accounts::Concerns::BillingV2 + extend ActiveSupport::Concern + + included do + before_action :validate_topup_amount, only: [:v2_topup] + end + + def credit_grants + service = Enterprise::Billing::V2::CreditManagementService.new(account: @account) + grants = service.fetch_credit_grants + + render json: { credit_grants: grants } + end + + def v2_pricing_plans + plans = Enterprise::Billing::V2::PlanCatalog.plans + render json: { pricing_plans: plans } + end + + def v2_topup_options + options = Enterprise::Billing::V2::TopupCatalog.options + render json: { topup_options: options } + end + + def v2_topup + render json: { success: true, message: 'Topup successful.' } + end + + def v2_subscribe + service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account) + result = service.create_subscription_checkout( + pricing_plan_id: params[:pricing_plan_id], + quantity: subscription_quantity + ) + + if result[:success] + render json: { success: true, redirect_url: result[:redirect_url], session_id: result[:session_id] } + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + def cancel_subscription + render json: { success: true, message: 'Subscription cancelled.' } + end + + def change_pricing_plan + render json: { success: true, message: 'Pricing plan changed.' } + end + + private + + def subscription_quantity + [params[:quantity].to_i, 1].max + end + + def validate_topup_amount + return if params[:credits].to_i.positive? + + render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity + end +end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 339fbdf3c..7fed4dfcf 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -1,5 +1,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper + include Enterprise::Api::V1::Accounts::Concerns::BillingV2 + before_action :fetch_account before_action :check_authorization before_action :check_cloud_env, only: [:limits, :toggle_deletion] diff --git a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb index 0b66182d1..7c248659d 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -6,8 +6,17 @@ class Enterprise::Webhooks::StripeController < ActionController::API # Attempt to verify the signature. If successful, we'll handle the event begin - event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)) - ::Enterprise::Billing::HandleStripeEventService.new.perform(event: event) + # Determine which webhook secret to use based on event type + webhook_secret = determine_webhook_secret(payload) + + event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret) + + # Check if this is a V2 billing event + if v2_billing_event?(event.type) + ::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event) + else + ::Enterprise::Billing::HandleStripeEventService.new.perform(event: event) + end # If we fail to verify the signature, then something was wrong with the request rescue JSON::ParserError, Stripe::SignatureVerificationError # Invalid payload @@ -18,4 +27,23 @@ class Enterprise::Webhooks::StripeController < ActionController::API # We've successfully processed the event without blowing up head :ok end + + private + + def determine_webhook_secret(payload) + # Parse the payload to check event type without full verification + parsed_payload = JSON.parse(payload) + event_type = parsed_payload['type'] + + if v2_billing_event?(event_type) + ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil) + else + ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) + end + end + + def v2_billing_event?(event_type) + Rails.logger.debug { "V2 billing event: #{event_type}" } + event_type.start_with?('v2.') + end end diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb index ce03efa41..03809bd12 100644 --- a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -1,6 +1,13 @@ module Enterprise::Account::PlanUsageAndLimits + # Total credits CAPTAIN_RESPONSES = 'captain_responses'.freeze CAPTAIN_DOCUMENTS = 'captain_documents'.freeze + + # Response credits breakdown (monthly + topup) + CAPTAIN_RESPONSES_MONTHLY = 'captain_responses_monthly'.freeze + CAPTAIN_RESPONSES_TOPUP = 'captain_responses_topup'.freeze + + # Usage tracking CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze @@ -16,8 +23,7 @@ module Enterprise::Account::PlanUsageAndLimits end def increment_response_usage - current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 - custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1 + custom_attributes[CAPTAIN_RESPONSES_USAGE] = (custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0) + 1 save end @@ -58,11 +64,12 @@ module Enterprise::Account::PlanUsageAndLimits else custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0 end - consumed = 0 if consumed.negative? { total_count: total_count, + monthly: (self[:limits][CAPTAIN_RESPONSES_MONTHLY].to_i if type == :responses), + topup: (self[:limits][CAPTAIN_RESPONSES_TOPUP].to_i if type == :responses), current_available: (total_count - consumed).clamp(0, total_count), consumed: consumed } @@ -96,17 +103,12 @@ module Enterprise::Account::PlanUsageAndLimits end def agent_limits - subscribed_quantity = custom_attributes['subscribed_quantity'] - subscribed_quantity || get_limits(:agents) + custom_attributes['subscribed_quantity'] || get_limits(:agents) end def get_limits(limit_name) config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT" - return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present? - - return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present? - - ChatwootApp.max_limit + self[:limits][limit_name.to_s].presence || GlobalConfig.get(config_name)[config_name].presence || ChatwootApp.max_limit end def validate_limit_keys @@ -119,7 +121,9 @@ module Enterprise::Account::PlanUsageAndLimits 'inboxes' => { 'type': 'number' }, 'agents' => { 'type': 'number' }, 'captain_responses' => { 'type': 'number' }, - 'captain_documents' => { 'type': 'number' } + 'captain_documents' => { 'type': 'number' }, + 'captain_responses_monthly' => { 'type': 'number' }, + 'captain_responses_topup' => { 'type': 'number' } }, 'required' => [], 'additionalProperties' => false diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb new file mode 100644 index 000000000..5a5e955fe --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb @@ -0,0 +1,90 @@ +module Enterprise::Billing::Concerns::PlanFeatureManager + extend ActiveSupport::Concern + + # Plan hierarchy: Hacker (default) -> Startup -> Business -> Enterprise + # Each higher tier includes all features from the lower tiers + + # Basic features available starting with the Startup plan + STARTUP_PLAN_FEATURES = %w[ + inbound_emails + help_center + campaigns + team_management + channel_twitter + channel_facebook + channel_email + channel_instagram + captain_integration + advanced_search_indexing + advanced_search + ].freeze + + # Additional features available starting with the Business plan + BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze + + # Additional features available only in the Enterprise plan + ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze + + def update_plan_features(plan_name) + if plan_name.blank? || plan_name == 'Hacker' + disable_all_premium_features + else + enable_features_for_current_plan(plan_name) + end + + # Enable any manually managed features configured in internal_attributes + enable_account_manually_managed_features + + account.save! + end + + def disable_all_premium_features + # Disable all features (for default Hacker plan or during plan changes) + account.disable_features(*STARTUP_PLAN_FEATURES) + account.disable_features(*BUSINESS_PLAN_FEATURES) + account.disable_features(*ENTERPRISE_PLAN_FEATURES) + end + + def enable_features_for_current_plan(plan_name) + # First disable all premium features to handle downgrades + disable_all_premium_features + + # Then enable features based on the current plan + enable_plan_specific_features(plan_name) + end + + def enable_plan_specific_features(plan_name) + return if plan_name.blank? + + # Enable features based on plan hierarchy + case plan_name + when 'Startup', 'Startups' + # Startup plan gets the basic features + account.enable_features(*STARTUP_PLAN_FEATURES) + when 'Business' + # Business plan gets Startup features + Business features + account.enable_features(*STARTUP_PLAN_FEATURES) + account.enable_features(*BUSINESS_PLAN_FEATURES) + when 'Enterprise' + # Enterprise plan gets all features + account.enable_features(*STARTUP_PLAN_FEATURES) + account.enable_features(*BUSINESS_PLAN_FEATURES) + account.enable_features(*ENTERPRISE_PLAN_FEATURES) + end + end + + def reset_captain_usage + account.reset_response_usage if account.respond_to?(:reset_response_usage) + end + + private + + def enable_account_manually_managed_features + # Get manually managed features from internal attributes using the service + service = Internal::Accounts::InternalAttributesService.new(account) + features = service.manually_managed_features + + # Enable each feature + account.enable_features(*features) if features.present? + end +end 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..cb5447c48 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb @@ -0,0 +1,42 @@ +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) + return unless plan_definition + + plan_name = extract_plan_name(plan_definition) + enable_plan_specific_features(plan_name) if plan_name.present? + end + + def sync_plan_credits(pricing_plan_id) + plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id) + + Enterprise::Billing::V2::CreditManagementService + .new(account: account) + .sync_monthly_response_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/stripe_v2_client_helper.rb b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb new file mode 100644 index 000000000..f48501efc --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb @@ -0,0 +1,65 @@ +module Enterprise::Billing::Concerns::StripeV2ClientHelper + extend ActiveSupport::Concern + + private + + # Stripe client instance with API key + def stripe_client + @stripe_client ||= Stripe::StripeClient.new(ENV.fetch('STRIPE_SECRET_KEY', nil)) + end + + # Pricing Plan Subscriptions + def retrieve_pricing_plan_subscription(subscription_id) + stripe_client.v2.billing.pricing_plan_subscriptions.retrieve(subscription_id) + end + + # Pricing Plans + def retrieve_pricing_plan(pricing_plan_id) + stripe_client.v2.billing.pricing_plans.retrieve(pricing_plan_id) + end + + def retrieve_billing_cadence(cadence_id) + stripe_client.v2.billing.cadences.retrieve(cadence_id) + end + + def create_billing_intent(params) + response = Faraday.post('https://api.stripe.com/v2/billing/intents') do |req| + req.headers['Authorization'] = "Bearer #{ENV.fetch('STRIPE_SECRET_KEY', nil)}" + req.headers['Stripe-Version'] = default_stripe_version + req.headers['Content-Type'] = 'application/json' + req.body = params.to_json + end + + JSON.parse(response.body) + end + + def reserve_billing_intent(billing_intent_id) + stripe_client.v2.billing.intents.reserve(billing_intent_id) + end + + def commit_billing_intent(billing_intent_id) + stripe_client.v2.billing.intents.commit(billing_intent_id) + end + + # Checkout Sessions (V1 API but used with V2 plans) + def create_checkout_session(params) + Stripe::Checkout::Session.create(params, { stripe_version: checkout_stripe_version }) + end + + # Credit Grants (V1 API but used with V2) + def retrieve_credit_grant(grant_id) + Stripe::Billing::CreditGrant.retrieve(grant_id) + end + + def default_stripe_version + '2025-10-29.preview' + end + + def checkout_stripe_version + '2025-10-29.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/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb index e4df1050b..05f8048cf 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -1,4 +1,6 @@ class Enterprise::Billing::CreateStripeCustomerService + include Enterprise::Billing::Concerns::PlanFeatureManager + pattr_initialize [:account!] DEFAULT_QUANTITY = 2 @@ -6,22 +8,11 @@ class Enterprise::Billing::CreateStripeCustomerService def perform return if existing_subscription? + raise_config_error unless v2_configs_present? + customer_id = prepare_customer_id - subscription = Stripe::Subscription.create( - { - customer: customer_id, - items: [{ price: price_id, quantity: default_quantity }] - } - ) - account.update!( - custom_attributes: { - stripe_customer_id: customer_id, - stripe_price_id: subscription['plan']['id'], - stripe_product_id: subscription['plan']['product'], - plan_name: default_plan['name'], - subscribed_quantity: subscription['quantity'] - } - ) + update_account_for_v2_billing(customer_id) + enable_plan_specific_features('Hacker') end private @@ -35,22 +26,16 @@ class Enterprise::Billing::CreateStripeCustomerService customer_id end - def default_quantity - default_plan['default_quantity'] || DEFAULT_QUANTITY - end - def billing_email account.administrators.first.email end - def default_plan - installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS') - @default_plan ||= installation_config.value.first + def v2_configs_present? + InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').present? end - def price_id - price_ids = default_plan['price_ids'] - price_ids.first + def raise_config_error + raise StandardError, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.' end def existing_subscription? @@ -66,4 +51,23 @@ class Enterprise::Billing::CreateStripeCustomerService ) subscriptions.data.present? end + + def update_account_for_v2_billing(customer_id) + hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID') + + attributes = { + stripe_customer_id: customer_id, + stripe_billing_version: 2 + } + + if hacker_plan_config&.value.present? + attributes.merge!( + stripe_pricing_plan_id: hacker_plan_config.value, + plan_name: 'Hacker', + subscribed_quantity: DEFAULT_QUANTITY + ) + end + + account.update!(custom_attributes: attributes) + 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 5364409a1..5343e9ef0 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -1,30 +1,9 @@ class Enterprise::Billing::HandleStripeEventService + include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::StripeV2ClientHelper + CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze - # Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise - # Each higher tier includes all features from the lower tiers - - # Basic features available starting with the Startups plan - STARTUP_PLAN_FEATURES = %w[ - inbound_emails - help_center - campaigns - team_management - channel_twitter - channel_facebook - channel_email - channel_instagram - captain_integration - advanced_search_indexing - advanced_search - ].freeze - - # Additional features available starting with the Business plan - BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze - - # Additional features available only in the Enterprise plan - ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze - def perform(event:) @event = event @@ -33,6 +12,8 @@ class Enterprise::Billing::HandleStripeEventService process_subscription_updated when 'customer.subscription.deleted' process_subscription_deleted + when 'billing.credit_grant.created' + process_credit_grant_created else Rails.logger.debug { "Unhandled event type: #{event.type}" } end @@ -47,7 +28,8 @@ class Enterprise::Billing::HandleStripeEventService return if plan.blank? || account.blank? update_account_attributes(subscription, plan) - update_plan_features + plan_name = account.custom_attributes['plan_name'] + update_plan_features(plan_name) reset_captain_usage end @@ -73,65 +55,22 @@ class Enterprise::Billing::HandleStripeEventService Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform end - def update_plan_features - if default_plan? - disable_all_premium_features - else - enable_features_for_current_plan - end - - # Enable any manually managed features configured in internal_attributes - enable_account_manually_managed_features - - account.save! - end - - def disable_all_premium_features - # Disable all features (for default Hacker plan) - account.disable_features(*STARTUP_PLAN_FEATURES) - account.disable_features(*BUSINESS_PLAN_FEATURES) - account.disable_features(*ENTERPRISE_PLAN_FEATURES) - end - - def enable_features_for_current_plan - # First disable all premium features to handle downgrades - disable_all_premium_features - - # Then enable features based on the current plan - enable_plan_specific_features - end - - def reset_captain_usage - account.reset_response_usage - end - - def enable_plan_specific_features - plan_name = account.custom_attributes['plan_name'] - return if plan_name.blank? - - # Enable features based on plan hierarchy - case plan_name - when 'Startups' - # Startups plan gets the basic features - account.enable_features(*STARTUP_PLAN_FEATURES) - when 'Business' - # Business plan gets Startups features + Business features - account.enable_features(*STARTUP_PLAN_FEATURES) - account.enable_features(*BUSINESS_PLAN_FEATURES) - when 'Enterprise' - # Enterprise plan gets all features - account.enable_features(*STARTUP_PLAN_FEATURES) - account.enable_features(*BUSINESS_PLAN_FEATURES) - account.enable_features(*ENTERPRISE_PLAN_FEATURES) - end - end - def subscription @subscription ||= @event.data.object end def account - @account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first + @account ||= begin + customer_id = if @event.type.start_with?('billing.credit_grant') + # Credit grant events have customer directly on the object + @event.data.object.respond_to?(:customer) ? @event.data.object.customer : @event.data.object['customer'] + else + # Subscription events have customer on subscription + subscription.customer + end + + Account.where("custom_attributes->>'stripe_customer_id' = ?", customer_id).first + end end def find_plan(plan_id) @@ -139,18 +78,49 @@ class Enterprise::Billing::HandleStripeEventService cloud_plans.find { |config| config['product_id'].include?(plan_id) } end - def default_plan? - cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] - default_plan = cloud_plans.first || {} - account.custom_attributes['plan_name'] == default_plan['name'] + def process_credit_grant_created + grant_id = extract_credit_grant_id(@event.data.object) + return if grant_id.blank? + + # Retrieve the full credit grant object from Stripe API + grant = retrieve_credit_grant(grant_id) + return if grant.blank? + + amount = extract_credit_amount(grant) + return if amount.zero? + + service = Enterprise::Billing::V2::CreditManagementService.new(account: account) + service.add_response_topup_credits(amount) end - def enable_account_manually_managed_features - # Get manually managed features from internal attributes using the service - service = Internal::Accounts::InternalAttributesService.new(account) - features = service.manually_managed_features + def extract_credit_grant_id(grant_object) + grant_object.respond_to?(:id) ? grant_object.id : grant_object['id'] + end - # Enable each feature - account.enable_features(*features) if features.present? + def extract_credit_amount(grant) + # First, try to get credits from metadata + metadata = extract_attribute(grant, :metadata) + if metadata + credits = extract_attribute(metadata, :credits) + return credits.to_i if credits.present? && credits.to_i.positive? + end + + # Fallback: extract from amount object + amount_data = extract_attribute(grant, :amount) + return 0 unless amount_data + + 0 + end + + def extract_attribute(object, attribute) + object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s] + end + + def extract_amount_value(amount_data, unit_type) + unit = extract_attribute(amount_data, unit_type) + return 0 unless unit + + value = extract_attribute(unit, :value) + value.to_i end end diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb new file mode 100644 index 000000000..7ab1ccd9c --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -0,0 +1,90 @@ +class Enterprise::Billing::V2::BaseService + attr_reader :account + + def initialize(account:) + @account = account + end + + private + + def stripe_client + @stripe_client ||= Stripe::StripeClient.new( + api_key: ENV.fetch('STRIPE_SECRET_KEY', nil) + ) + end + + def response_monthly_credits + account.limits&.[]('captain_responses_monthly').to_i + end + + def response_topup_credits + account.limits&.[]('captain_responses_topup').to_i + end + + def response_usage + account.custom_attributes&.[]('captain_responses_usage').to_i + end + + # Update response credits (monthly/topup with auto-calculation of total) + def update_response_credits(monthly: nil, topup: nil) + # Calculate and update total in limits hash ONLY + return unless monthly || topup + + new_monthly = monthly || response_monthly_credits + new_topup = topup || response_topup_credits + total_credits = new_monthly + new_topup + limits = { + 'captain_responses_monthly' => new_monthly, + 'captain_responses_topup' => new_topup, + 'captain_responses' => total_credits + } + update_limits(limits) + end + + def update_limits(updates) + return if updates.blank? + + current_limits = account.limits.present? ? account.limits.deep_dup : {} + updates.each do |key, value| + current_limits[key.to_s] = value + end + + account.update!(limits: current_limits) + end + + def update_custom_attributes(updates) + return if updates.blank? + + current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {} + updates.each do |key, value| + current_attributes[key.to_s] = value + end + + account.update!(custom_attributes: current_attributes) + end + + def custom_attribute(key) + account.custom_attributes&.[](key.to_s) + end + + def with_locked_account(&) + account.with_lock(&) + end + + # Convenient accessors for common attributes + def stripe_customer_id + custom_attribute('stripe_customer_id') + end + + def stripe_subscription_id + custom_attribute('stripe_subscription_id') + end + + def pricing_plan_id + custom_attribute('stripe_pricing_plan_id') + end + + def subscribed_quantity + custom_attribute('subscribed_quantity').to_i + 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 new file mode 100644 index 000000000..4e21bf0e5 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb @@ -0,0 +1,85 @@ +class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::StripeV2ClientHelper + + def create_subscription_checkout(pricing_plan_id:, quantity: 1) + @pricing_plan_id = pricing_plan_id + @quantity = quantity.to_i.positive? ? quantity.to_i : 1 + + base_url = ENV.fetch('FRONTEND_URL') + @success_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing?session_id={CHECKOUT_SESSION_ID}" + @cancel_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing" + + validate_params + store_pending_subscription_quantity + session = create_checkout_session(checkout_session_params) + { success: true, redirect_url: session.url } + end + + private + + def validate_params + raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank? + end + + def store_pending_subscription_quantity + # Store quantity in custom_attributes for webhook to use + # This is more reliable than extracting from subscription component_values + update_custom_attributes({ + 'pending_subscription_quantity' => @quantity, + 'pending_subscription_pricing_plan' => @pricing_plan_id + }) + end + + def checkout_session_params + { + customer: stripe_customer_id, + checkout_items: build_checkout_items, + automatic_tax: { + enabled: true + }, + customer_update: { + address: 'auto', + shipping: 'auto' + }, + success_url: @success_url, + cancel_url: @cancel_url, + metadata: session_metadata + } + end + + def build_checkout_items + lookup_key = extract_license_lookup_key + raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key + + [ + { + type: 'pricing_plan_subscription_item', + pricing_plan_subscription_item: { + pricing_plan: @pricing_plan_id, + component_configurations: { + lookup_key => { + type: 'license_fee_component', + license_fee_component: { + quantity: @quantity + } + } + } + } + } + ] + end + + def extract_license_lookup_key + Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id) + end + + def session_metadata + { + account_id: account.id, + pricing_plan_id: @pricing_plan_id, + quantity: @quantity, + billing_version: 'v2' + } + 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 new file mode 100644 index 000000000..e4d9a8b39 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -0,0 +1,84 @@ +class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService + # Sync monthly response credits (resets on billing cycle with topup preservation) + def sync_monthly_response_credits(amount) + with_locked_account do + # Preserve topup credits but cap at remaining balance + preserved_topup = preserve_topup_on_reset( + current_topup: response_topup_credits, + new_monthly: amount, + current_usage: response_usage + ) + update_response_credits(monthly: amount, topup: preserved_topup) + end + end + + # Add topup credits for responses + def add_response_topup_credits(amount) + with_locked_account do + new_topup = response_topup_credits + amount + update_response_credits(topup: new_topup) + end + end + + def fetch_credit_grants + return [] if stripe_customer_id.blank? + + response = Stripe::Billing::CreditGrant.list( + { customer: stripe_customer_id, limit: 100 } + ) + + grants = response.data.map do |grant| + transform_credit_grant(grant) + end + grants.reject { |grant| grant[:credits].zero? } + rescue Stripe::StripeError => e + Rails.logger.error("Failed to fetch credit grants: #{e.message}") + [] + end + + private + + # Preserve topup credits on monthly reset, capped at remaining balance + # Formula: min(current_topup, max(0, (new_monthly + current_topup) - current_usage)) + def preserve_topup_on_reset(current_topup:, new_monthly:, current_usage:) + # Calculate remaining balance after usage + total_after_sync = new_monthly + current_topup + remaining_balance = [total_after_sync - current_usage, 0].max + + # Cap topup at remaining balance to avoid over-crediting + [current_topup, remaining_balance].min + end + + def transform_credit_grant(grant) + category = grant_attribute(grant, :category) + metadata = grant_attribute(grant, :metadata) || {} + + { + id: grant_attribute(grant, :id), + name: grant_attribute(grant, :name), + credits: calculate_grant_credits(category, metadata), + category: category, + source: metadata['source'] || category, + effective_at: parse_timestamp(grant_attribute(grant, :effective_at)), + expires_at: parse_timestamp(grant_attribute(grant, :expires_at)), + voided_at: parse_timestamp(grant_attribute(grant, :voided_at)), + created_at: parse_timestamp(grant_attribute(grant, :created)) + } + end + + def grant_attribute(grant, key) + grant[key] || grant.public_send(key) + end + + def calculate_grant_credits(category, metadata) + return metadata['credits'].to_i if category == 'paid' && metadata['credits'] + + 0 + end + + def parse_timestamp(timestamp) + return nil unless timestamp + + Time.zone.at(timestamp) + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb new file mode 100644 index 000000000..c0d9583df --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -0,0 +1,112 @@ +module Enterprise::Billing::V2::PlanCatalog + DEFAULT_CURRENCY = 'usd'.freeze + CREDIT_UNIT = 'Credits'.freeze + + PLAN_DEFINITIONS = [ + { + key: :free, + display_name: 'Chatwoot Hacker', + base_fee: 0.0, + monthly_credits: 0, + config_key: 'STRIPE_HACKER_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_hacker_license_fee_v2' + }, + { + key: :startup, + display_name: 'Chatwoot Startup', + base_fee: 19.0, + monthly_credits: 300, + config_key: 'STRIPE_STARTUP_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2' + }, + { + key: :business, + display_name: 'Chatwoot Business', + base_fee: 39.0, + monthly_credits: 500, + config_key: 'STRIPE_BUSINESS_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_business_license_fee_v2' + }, + { + key: :enterprise, + display_name: 'Chatwoot Enterprise', + base_fee: 99.0, + monthly_credits: 800, + config_key: 'STRIPE_ENTERPRISE_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_enterprise_license_fee_v2' + } + ].freeze + + module_function + + def plans + PLAN_DEFINITIONS.map do |definition| + plan_id = plan_id_for(definition) + build_plan(definition, plan_id) + end + end + + def definition_for(plan_id) + PLAN_DEFINITIONS.each do |definition| + return definition if plan_id_for(definition) == plan_id + end + nil + end + + def monthly_credits_for(plan_id) + definition = definition_for(plan_id) + definition ? definition[:monthly_credits] : nil + end + + def plan_id_for(definition) + InstallationConfig.find_by(name: definition[:config_key])&.value + end + + def lookup_key_for_plan(plan_id) + # Returns the licensed_item_lookup_key for checkout sessions + definition = definition_for(plan_id) + definition&.dig(:licensed_item_lookup_key) + end + + def build_plan(definition, plan_id) + { + id: plan_id, + display_name: definition[:display_name], + currency: DEFAULT_CURRENCY, + tax_behavior: 'exclusive', + components: build_components(definition) + } + end + + def build_components(definition) + components = [service_action_component(definition), rate_card_component(definition)] + components << license_fee_component(definition) if definition[:base_fee]&.positive? + components + end + + def service_action_component(definition) + { + type: 'service_action', + name: 'Monthly Credits', + credit_amount: definition[:monthly_credits], + credit_unit: CREDIT_UNIT + } + end + + def rate_card_component(_definition) + { + type: 'rate_card', + name: 'Overage Rate', + rate_unit: CREDIT_UNIT, + meter_id: nil + } + end + + def license_fee_component(definition) + { + type: 'license_fee', + name: 'Base Fee', + unit_amount: definition[:base_fee].round(2) + } + 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 new file mode 100644 index 000000000..6b0018e0d --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -0,0 +1,116 @@ +class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::PlanProvisioningHelper + include Enterprise::Billing::Concerns::StripeV2ClientHelper + + def provision(subscription_id:) + process_subscription(subscription_id) + end + + def refresh + return if stripe_subscription_id.blank? + + process_subscription(stripe_subscription_id) + end + + private + + def process_subscription(subscription_id) + # Retrieve pricing plan subscription details from Stripe V2 API + subscription = retrieve_pricing_plan_subscription(subscription_id) + + # Check if subscription is canceled + if servicing_status(subscription) == 'canceled' + cancel_subscription + reset_captain_usage + return { pricing_plan_id: nil, quantity: nil } + end + + # Extract details from the subscription + pricing_plan_id = extract_pricing_plan_id(subscription) + quantity = extract_subscription_quantity(subscription) + billing_cadence = extract_billing_cadence(subscription) + + # Update account with subscription details + update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence) + + # Provision the subscription: sync credits and enable features + provision_new_plan(pricing_plan_id) if pricing_plan_id.present? + + # Reset usage for the new billing cycle + reset_captain_usage + + { pricing_plan_id: pricing_plan_id, quantity: quantity } + end + + def servicing_status(subscription_plan) + extract_attribute(subscription_plan, :servicing_status) + end + + def cancel_subscription + hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID') + pricing_plan_id = hacker_plan_config.value + + # Update subscription status and plan details + attributes = { + 'plan_name': 'Hacker', + 'stripe_pricing_plan_id': pricing_plan_id, + 'subscribed_quantity': 2, + 'stripe_subscription_id': nil, + 'billing_cadence': nil, + 'subscription_status': 'canceled' + } + update_custom_attributes(attributes) + + # Sync credits for Hacker plan (0 credits) + sync_plan_credits(pricing_plan_id) + + # Disable all premium features and save + disable_all_premium_features + account.save! + end + + def extract_pricing_plan_id(subscription) + extract_attribute(subscription, :pricing_plan) + end + + def extract_billing_cadence(subscription) + extract_attribute(subscription, :billing_cadence) + end + + def extract_subscription_quantity(_subscription) + # Get quantity from account custom_attributes (set during checkout) + pending_quantity = custom_attribute('pending_subscription_quantity') + return pending_quantity.to_i if pending_quantity.present? && pending_quantity.to_i.positive? + + return subscribed_quantity if subscribed_quantity.positive? + + 1 + end + + def update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence) + Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \ + "pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}" + + attributes = { + 'stripe_subscription_id' => subscription_id, + 'subscribed_quantity' => quantity, + 'subscription_status' => 'active', + 'pending_subscription_quantity' => nil, + 'pending_subscription_pricing_plan' => nil, + 'billing_cadence' => billing_cadence, + 'next_billing_date' => nil, + 'pending_stripe_pricing_plan_id' => nil, + 'stripe_billing_version' => 2 + } + attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present? + + # Add plan name from catalog + if pricing_plan_id.present? + plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id) + attributes['plan_name'] = plan_definition[:display_name] if plan_definition + end + + update_custom_attributes(attributes) + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/topup_catalog.rb b/enterprise/app/services/enterprise/billing/v2/topup_catalog.rb new file mode 100644 index 000000000..1106a15ed --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/topup_catalog.rb @@ -0,0 +1,34 @@ +module Enterprise::Billing::V2::TopupCatalog + DEFAULT_TOPUPS = [ + { credits: 1000, amount: 20.0 }, + { credits: 2000, amount: 40.0 }, + { credits: 3000, amount: 60.0 }, + { credits: 4000, amount: 80.0 } + ].freeze + + module_function + + def options + custom_options = InstallationConfig.find_by(name: 'STRIPE_TOPUP_OPTIONS')&.value + parsed = parse_options(custom_options) + (parsed.presence || DEFAULT_TOPUPS).sort_by { |opt| opt[:credits] }.map do |option| + { + credits: option[:credits], + amount: option[:amount], + currency: option[:currency] || 'usd' + } + end + end + + def parse_options(raw) + return [] if raw.blank? + + JSON.parse(raw, symbolize_names: true) + rescue JSON::ParserError + [] + end + + def find_option(credits) + options.find { |option| option[:credits].to_i == credits.to_i } + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb new file mode 100644 index 000000000..ee569b217 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -0,0 +1,60 @@ +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? + + return { success: false, message: 'Account not found' } if account.blank? + + case @event.type + when 'v2.billing.pricing_plan_subscription.servicing_activated' + Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}" + handle_subscription_servicing_activated(@event.related_object.id) + when 'v2.billing.cadence.billed' + Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}" + refresh_account_subscription_details(@event.related_object.id) + else + { success: true } + end + rescue StandardError => e + Rails.logger.error "Error processing V2 webhook: #{e.message}" + { success: false, error: e.message } + end + + private + + def account + @account ||= begin + related_object = @event.related_object + subscription_id = related_object.id + + customer_id = fetch_customer_id_from_subscription(subscription_id) + found_account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) if customer_id.present? + + Rails.logger.warn "Could not find account for subscription #{subscription_id}" if found_account.blank? + + found_account + end + end + + def fetch_customer_id_from_subscription(subscription_id) + subscription = retrieve_pricing_plan_subscription(subscription_id) + return nil unless subscription&.billing_cadence + + cadence = retrieve_billing_cadence(subscription.billing_cadence) + cadence.payer&.customer + end + + def handle_subscription_servicing_activated(subscription_id) + Enterprise::Billing::V2::SubscriptionProvisioningService + .new(account: account) + .provision(subscription_id: subscription_id) + end + + def refresh_account_subscription_details(_cadence_id) + Enterprise::Billing::V2::SubscriptionProvisioningService + .new(account: account) + .refresh + end +end