diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d81b4c9da..f154cc315 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -71,6 +71,7 @@ class DashboardController < ActionController::Base WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), + STRIPE_BILLING_V2_ENABLED: GlobalConfigService.load('STRIPE_BILLING_V2_ENABLED', 'false'), GIT_SHA: GIT_HASH } end diff --git a/create_plans.rb b/create_plans.rb index 3b3974af6..3e3a85d71 100644 --- a/create_plans.rb +++ b/create_plans.rb @@ -64,7 +64,7 @@ configs = [ meter_event_name: 'chatwoot.usage', plan_display_name: 'Chatwoot Business', plan_lookup_key: 'chatwoot_business_v2', - lookup_key: 'chatwoot_business_plan_license_fee_v2', + lookup_key: 'chatwoot_business_license_fee_v2', service_action_lookup_key: 'chatwoot_business_plan_v2_credits', metered_item_lookup_key: 'chatwoot_business_plan_v2_usage', licensed_item_display_name: 'Seat', 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..f75980bc4 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 @@ -7,21 +9,14 @@ class Enterprise::Billing::CreateStripeCustomerService return if existing_subscription? 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'] - } - ) + + if billing_v2_enabled? + update_account_for_v2_billing(customer_id) + enable_plan_specific_features(default_plan['name']) + else + subscription = create_subscription(customer_id) + update_account_with_subscription(customer_id, subscription) + end end private @@ -53,6 +48,10 @@ class Enterprise::Billing::CreateStripeCustomerService price_ids.first end + def billing_v2_enabled? + ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' + end + def existing_subscription? stripe_customer_id = account.custom_attributes['stripe_customer_id'] return false if stripe_customer_id.blank? @@ -66,4 +65,34 @@ class Enterprise::Billing::CreateStripeCustomerService ) subscriptions.data.present? end + + def create_subscription(customer_id) + Stripe::Subscription.create( + customer: customer_id, + items: [{ price: price_id, quantity: default_quantity }] + ) + end + + def update_account_for_v2_billing(customer_id) + account.update!( + custom_attributes: { + stripe_customer_id: customer_id, + stripe_pricing_plan_id: InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').value, + plan_name: 'Hacker', + subscribed_quantity: DEFAULT_QUANTITY + } + ) + end + + def update_account_with_subscription(customer_id, subscription) + 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'] + } + ) + end end 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 98fd5a707..34fa145ae 100644 --- a/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb @@ -8,9 +8,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: # @return [Hash] { success:, cancel_at_period_end:, period_end:, message: } # def cancel_subscription - return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? - return { success: false, message: 'No active subscription' } unless active_subscription? - with_locked_account do billing_intent = create_deactivate_intent reserve_billing_intent(billing_intent) @@ -55,6 +52,7 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: def store_next_billing_date(cadence_id) cadence = retrieve_billing_cadence(cadence_id) + Rails.logger.info("Cadence: #{cadence}") @next_billing_date = extract_attribute(cadence, :next_billing_date) end @@ -126,14 +124,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: } end - def v2_enabled? - custom_attribute('stripe_billing_version')&.to_i == 2 - end - - def active_subscription? - custom_attribute('subscription_status') == 'active' - end - def stripe_api_options { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } end 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 a2835ff2a..ec2dc123e 100644 --- a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb @@ -9,8 +9,6 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base # @return [Hash] { success:, message: } # def change_plan(new_pricing_plan_id:, quantity: 1) - return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? - return { success: false, message: 'No active subscription' } unless active_subscription? return { success: false, message: 'Invalid quantity' } unless quantity.positive? with_locked_account do @@ -179,14 +177,6 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base } end - def v2_enabled? - custom_attribute('stripe_billing_version')&.to_i == 2 - end - - def active_subscription? - custom_attribute('subscription_status') == 'active' - end - def stripe_api_options { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } end diff --git a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb index ac459d14f..2ca8270e0 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -99,9 +99,10 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 stripe_api_options ) - response.data.map do |grant| + 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}") [] @@ -132,36 +133,32 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 private - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength def transform_credit_grant(grant) - # Stripe objects have symbol keys when converted to hash, or access via methods - # Use the grant object directly for most reliable access - category = grant[:category] || grant.category - metadata = grant[:metadata] || grant.metadata || {} - - # Determine credits amount based on grant type - credits = if category == 'paid' && metadata['credits'] - metadata['credits'].to_i - elsif category == 'promotional' - grant_id = grant[:id] || grant.id - fetch_monthly_credit_amount(grant_id) - else - 0 - end + category = grant_attribute(grant, :category) + metadata = grant_attribute(grant, :metadata) || {} { - id: grant[:id] || grant.id, - name: grant[:name] || grant.name, - credits: credits, + 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[:effective_at] || grant.effective_at), - expires_at: parse_timestamp(grant[:expires_at] || grant.expires_at), - voided_at: parse_timestamp(grant[:voided_at] || grant.voided_at), - created_at: parse_timestamp(grant[:created] || grant.created) + 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 - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength + + 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 @@ -169,20 +166,6 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 Time.zone.at(timestamp) end - def fetch_monthly_credit_amount(grant_id) - # Try to get from credit transactions with matching grant_id in metadata - transaction = account.credit_transactions - .where(transaction_type: 'grant', credit_type: 'monthly') - .where("metadata->>'grant_id' = ?", grant_id) - .order(created_at: :desc) - .first - - return transaction.amount if transaction - - # Fallback: return the current monthly_credits from account - monthly_credits - end - def stripe_customer_id custom_attribute('stripe_customer_id') end diff --git a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb index 21413b9bd..390049eba 100644 --- a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -25,7 +25,7 @@ module Enterprise::Billing::V2::PlanCatalog base_fee: 39.0, monthly_credits: 50_000, config_key: 'STRIPE_BUSINESS_PLAN_ID', - licensed_item_lookup_key: 'chatwoot_business_plan_license_fee_v2' + licensed_item_lookup_key: 'chatwoot_business_license_fee_v2' }, { key: :enterprise, 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 edebdce0c..c3a29819d 100644 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb @@ -42,14 +42,14 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas plan = create_pricing_plan(display_name: config[:plan_display_name], lookup_key: config[:plan_lookup_key]) builder = component_builder - builder.add_license_fee_component(plan, config) - service_action = builder.add_service_action_component(plan, config, cpu) - rate_card = builder.add_rate_card_component(plan, config, meter, cpu) + builder.add_license_fee_component(plan, config) if config[:license_fee_amount].to_i.positive? + builder.add_service_action_component(plan, config, cpu) if config[:monthly_credit_amount].to_i.positive? + builder.add_rate_card_component(plan, config, meter, cpu) # Make the latest version live make_plan_version_live(plan.id) - build_plan_result(plan, cpu, meter, rate_card, service_action) + build_plan_result(plan, cpu, meter) end def get_or_create_cpu(config) @@ -111,14 +111,12 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas ) end - def build_plan_result(plan, cpu, meter, rate_card, service_action) + def build_plan_result(plan, cpu, meter) { success: true, pricing_plan: plan, custom_pricing_unit: cpu, - meter: meter, - rate_card: rate_card, - service_action: service_action + meter: meter } 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 3fd9730cd..5cb829f7d 100644 --- a/enterprise/app/services/enterprise/billing/v2/topup_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/topup_service.rb @@ -19,18 +19,38 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi def validate_topup_request(credits) return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive? + # Check if account has a valid subscription plan + plan_validation = validate_subscription_plan + return plan_validation unless plan_validation[:valid] + topup_definition = Enterprise::Billing::V2::TopupCatalog.find_option(credits) return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank? # Check if customer has a default payment method unless customer_has_payment_method? - return { valid: false, success: false, message: 'No payment method found. Please add a payment method before making a purchase.' } + return { valid: false, success: false, + message: 'No default payment method found. Please add a default payment method before making a purchase.' } end { valid: true, topup_definition: topup_definition } end + def validate_subscription_plan + plan_name = custom_attribute('plan_name') + + # Block topup if no plan or on Hacker plan + if plan_name.blank? || plan_name.downcase == 'hacker' + return { + valid: false, + success: false, + message: 'Top-ups are only available for Startup, Business, and Enterprise plans. Please upgrade your plan to purchase credits.' + } + end + + { 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 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 6fc7eefe0..a4f10b127 100644 --- a/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb @@ -8,8 +8,6 @@ class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing:: # @return [Hash] { success:, message: } # def update_quantity(quantity:) - return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? - return { success: false, message: 'No active subscription' } unless active_subscription? return { success: false, message: 'Invalid quantity' } unless quantity.positive? with_locked_account do @@ -129,14 +127,6 @@ class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing:: } end - def v2_enabled? - custom_attribute('stripe_billing_version')&.to_i == 2 - end - - def active_subscription? - custom_attribute('subscription_status') == 'active' - end - def stripe_api_options { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } end