diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb index 8e922484e..e0c3e70e7 100644 --- a/config/initializers/stripe.rb +++ b/config/initializers/stripe.rb @@ -2,8 +2,8 @@ require 'stripe' Stripe.api_key = ENV.fetch('STRIPE_SECRET_KEY', nil) -# Set API version if specified -Stripe.api_version = ENV['STRIPE_API_VERSION'] if ENV['STRIPE_API_VERSION'].present? +# Set API version - using V2 preview version for credit management +Stripe.api_version = ENV['STRIPE_API_VERSION'] || '2025-08-27.preview' # V2 Billing Configuration Rails.application.config.stripe_v2 = { diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb index 353ec49b9..b671b6433 100644 --- a/enterprise/app/services/enterprise/billing/v2/base_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -8,7 +8,9 @@ class Enterprise::Billing::V2::BaseService private def stripe_client - @stripe_client ||= Stripe::StripeClient.new(api_key: ENV.fetch('STRIPE_SECRET_KEY', nil)) + @stripe_client ||= Stripe::StripeClient.new( + api_key: ENV.fetch('STRIPE_SECRET_KEY', nil) + ) end def v2_enabled? 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 cd6ba28d4..774d63576 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -1,8 +1,61 @@ +# rubocop:disable Metrics/ClassLength class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService + def fetch_stripe_credit_balance + return nil unless stripe_customer_id.present? && v2_enabled? + + with_stripe_error_handling do + response, _api_key = stripe_client.execute_request( + :get, + '/v1/billing/credit_grants', + params: { customer: stripe_customer_id, limit: 100 } + ) + + parse_credit_grants(response) + end + rescue StandardError => e + Rails.logger.error "Failed to fetch credit grants: #{e.message}" + nil + end + + def create_stripe_credit_grant(amount, type: 'promotional', metadata: {}) + return nil unless stripe_customer_id.present? + + params = { + customer: stripe_customer_id, + name: "#{type.titleize} Credits - #{Time.current.strftime('%Y-%m-%d')}", + amount: { + type: 'monetary', + monetary: { + currency: 'usd', + value: amount.to_i + } + }, + category: type == 'topup' ? 'paid' : 'promotional', + applicability_config: { scope: { price_type: 'metered' } }, + metadata: metadata.merge( + account_id: account.id.to_s, + created_by: 'chatwoot_v2', + credit_type: type, + credits: amount.to_s + ) + } + + params[:expiry_config] = { type: 'end_of_service_period' } if type == 'monthly' + + response, _api_key = stripe_client.execute_request(:post, '/v1/billing/credit_grants', params: params) + response.is_a?(Stripe::StripeResponse) ? response.data : response + rescue StandardError => e + Rails.logger.error "Failed to create credit grant: #{e.message}" + nil + end + def grant_monthly_credits(amount = 2000, metadata: {}) - result = with_locked_account do + with_locked_account do expired_amount = expire_current_monthly_credits(metadata: metadata) + stripe_grant = create_stripe_credit_grant(amount, type: 'monthly', metadata: metadata) + grant_id = stripe_grant ? (stripe_grant['id'] || stripe_grant[:id]) : nil + update_credits(monthly: amount) if amount.positive? @@ -11,17 +64,18 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 amount: amount, credit_type: 'monthly', description: 'Monthly credit grant', - metadata: base_metadata(metadata).merge('expired_amount' => expired_amount) + metadata: base_metadata(metadata).merge( + 'expired_amount' => expired_amount, + 'stripe_grant_id' => grant_id + ) ) end { success: true, granted: amount, expired: expired_amount, remaining: total_credits } end - Rails.logger.info "Granted #{amount} monthly credits to account #{account.id}" - result rescue ActiveRecord::RecordInvalid => e - Rails.logger.error "Failed to grant monthly credits to account #{account.id}: #{e.message}" + Rails.logger.error "Failed to grant monthly credits: #{e.message}" { success: false, message: e.message } end @@ -29,8 +83,18 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 def use_credit(feature: 'ai_captain', amount: 1, metadata: {}) return { success: true, credits_used: 0, remaining: total_credits } if amount <= 0 + reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account) + stripe_result = reporter.report(amount, feature, metadata) + + return { success: false, message: "Usage reporting failed: #{stripe_result[:message]}" } unless stripe_result[:success] + with_locked_account do - return { success: false, message: 'Insufficient credits' } if total_credits < amount + current_balance = credit_balance + + if current_balance[:total] < amount + Rails.logger.warn 'Local cache out of sync with Stripe' + return { success: false, message: 'Insufficient credits' } + end current_monthly = monthly_credits current_topup = topup_credits @@ -50,18 +114,29 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 amount: amount, credit_type: credit_type, description: "Used for #{feature}", - metadata: base_metadata(metadata).merge('feature' => feature) + metadata: base_metadata(metadata).merge( + 'feature' => feature, + 'stripe_event_id' => stripe_result[:event_id] + ) ) - Enterprise::Billing::V2::UsageReporterService.new(account: account).report_async(amount, feature) - - { success: true, credits_used: amount, remaining: total_credits } + final_balance = credit_balance + { + success: true, + credits_used: amount, + remaining: final_balance[:total], + source: final_balance[:source], + stripe_event_id: stripe_result[:event_id] + } end end # rubocop:enable Metrics/MethodLength def add_topup_credits(amount, metadata: {}) - result = with_locked_account do + with_locked_account do + stripe_grant = create_stripe_credit_grant(amount, type: 'topup', metadata: metadata) + grant_id = stripe_grant ? (stripe_grant['id'] || stripe_grant[:id]) : nil + new_balance = topup_credits + amount update_credits(topup: new_balance) @@ -70,16 +145,14 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 amount: amount, credit_type: 'topup', description: 'Topup credits added', - metadata: base_metadata(metadata) + metadata: base_metadata(metadata).merge('stripe_grant_id' => grant_id) ) { success: true, topup_balance: new_balance, total: total_credits } end - Rails.logger.info "Added #{amount} topup credits to account #{account.id}" - result rescue ActiveRecord::RecordInvalid => e - Rails.logger.error "Failed to add topup credits to account #{account.id}: #{e.message}" + Rails.logger.error "Failed to add topup credits: #{e.message}" { success: false, message: e.message } end @@ -88,12 +161,38 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end def credit_balance - { - monthly: monthly_credits, - topup: topup_credits, - total: total_credits, - last_synced: Time.current - } + stripe_usage = fetch_stripe_usage_total + initial_credits = initial_credits_from_local + + if stripe_usage.is_a?(Numeric) && initial_credits + total_used = stripe_usage + total_granted = initial_credits[:total_granted] + remaining = [total_granted - total_used, 0].max + + monthly_portion = [remaining, initial_credits[:monthly_granted]].min + topup_portion = [remaining - monthly_portion, 0].max + + balance = { + monthly: monthly_portion, + topup: topup_portion, + total: remaining, + usage_from_stripe: total_used, + granted_from_stripe: total_granted, + last_synced: Time.current, + source: 'stripe' + } + + sync_local_balance_from_stripe(balance) + balance + else + { + monthly: monthly_credits, + topup: topup_credits, + total: total_credits, + last_synced: Time.current, + source: 'local_fallback' + } + end end def expire_monthly_credits(metadata: {}) @@ -105,6 +204,127 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 private + def stripe_customer_id + custom_attribute('stripe_customer_id') + end + + def fetch_stripe_usage_total + return nil unless stripe_customer_id.present? && ENV['STRIPE_V2_METER_ID'].present? + + response, _api_key = stripe_client.execute_request( + :get, + "/v1/billing/meters/#{ENV.fetch('STRIPE_V2_METER_ID', nil)}/event_summaries", + headers: { 'Stripe-Version' => '2025-08-27.preview' }, + params: { + customer: stripe_customer_id, + start_time: Time.current.beginning_of_month.to_i, + end_time: Time.current.to_i + } + ) + + data = response.is_a?(Stripe::StripeResponse) ? response.data : response + summaries = extract_summaries(data) + + summaries.sum { |s| (s['aggregated_value'] || s[:aggregated_value] || 0).to_i } + rescue StandardError => e + Rails.logger.error "Failed to fetch meter summaries: #{e.message}" + nil + end + + def extract_summaries(data) + return [] unless data + + if data.is_a?(Hash) + data['data'] || data[:data] || [] + elsif data.is_a?(Array) + data + else + [] + end + end + + def initial_credits_from_local + monthly_granted = account.credit_transactions + .where(transaction_type: 'grant', credit_type: 'monthly') + .sum(:amount) + + topup_granted = account.credit_transactions + .where(transaction_type: 'topup') + .sum(:amount) + + { + monthly_granted: monthly_granted, + topup_granted: topup_granted, + total_granted: monthly_granted + topup_granted + } + end + + def parse_credit_grants(response) + return nil unless response + + data = response.is_a?(Stripe::StripeResponse) ? response.data : response + return nil unless data + + grants = data.is_a?(Hash) ? (data['data'] || data[:data] || []) : [] + return nil if grants.empty? + + monthly = 0 + topup = 0 + grant_details = [] + + grants.each do |grant| + voided_at = grant['voided_at'] || grant[:voided_at] + next unless voided_at.nil? + + amount_data = grant['amount'] || grant[:amount] + next unless amount_data + + available = extract_grant_amount(amount_data) + category = grant['category'] || grant[:category] + expiry_config = grant['expiry_config'] || grant[:expiry_config] + grant_id = grant['id'] || grant[:id] + + if category == 'paid' || expiry_config.nil? + topup += available + grant_details << { type: 'topup', amount: available, id: grant_id } + else + monthly += available + grant_details << { type: 'monthly', amount: available, id: grant_id, expiry_config: expiry_config } + end + end + + { + monthly: monthly, + topup: topup, + total: monthly + topup, + last_synced: Time.current, + source: 'stripe', + grant_details: grant_details + } + end + + def extract_grant_amount(amount_data) + amount_type = amount_data['type'] || amount_data[:type] + + case amount_type + when 'custom_pricing_unit' + cpu_data = amount_data['custom_pricing_unit'] || amount_data[:custom_pricing_unit] + (cpu_data&.[]('value') || cpu_data&.[](:value) || 0).to_i + when 'monetary' + monetary_data = amount_data['monetary'] || amount_data[:monetary] + (monetary_data&.[]('value') || monetary_data&.[](:value) || 0).to_i + else + 0 + end + end + + def sync_local_balance_from_stripe(stripe_balance) + update_credits( + monthly: stripe_balance[:monthly], + topup: stripe_balance[:topup] + ) + end + def expire_current_monthly_credits(metadata: {}) current_monthly = monthly_credits return 0 if current_monthly.zero? @@ -126,3 +346,4 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 metadata.is_a?(Hash) ? metadata.stringify_keys : {} end end +# rubocop:enable Metrics/ClassLength diff --git a/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb index 05d16d99d..f079c166f 100644 --- a/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb @@ -1,35 +1,193 @@ class Enterprise::Billing::V2::UsageAnalyticsService < Enterprise::Billing::V2::BaseService def fetch_usage_summary return { success: false, message: 'Not on V2 billing' } unless v2_enabled? - return { success: false, message: 'No Stripe customer' } if stripe_customer_id.blank? - with_stripe_error_handling do - # Get current month's usage from Stripe - start_time = Time.current.beginning_of_month.to_i - end_time = Time.current.to_i + # ALWAYS use Stripe as primary source + stripe_analytics = fetch_stripe_meter_events - # For now, return mock data as Stripe Billing Meters require setup - # In production, this would call Stripe's usage API - Rails.logger.info "Fetching usage for customer #{stripe_customer_id} from #{start_time} to #{end_time}" - - # Calculate usage from local credit transactions for current month - total_used = account.credit_transactions - .where(transaction_type: 'use', - created_at: Time.current.all_month) - .sum(:amount) - - { - success: true, - total_usage: total_used, - period_start: Time.zone.at(start_time), - period_end: Time.zone.at(end_time), - source: 'local' # Indicate this is from local data - } + if stripe_analytics && stripe_analytics[:success] + Rails.logger.info 'Using Stripe meter events as source of truth' + return stripe_analytics + else + Rails.logger.warn 'Stripe unavailable - using local cache as fallback' + # Only use local as fallback with warning + local_summary = fetch_local_usage_summary + local_summary[:warning] = 'Using cached data - Stripe unavailable' + local_summary end end + def fetch_stripe_meter_events + return nil unless stripe_customer_id.present? && ENV['STRIPE_V2_METER_ID'].present? + + begin + end_time = Time.current + start_time = end_time.beginning_of_month + meter_id = ENV.fetch('STRIPE_V2_METER_ID', nil) + + # Fetch meter event summaries + response = stripe_client.execute_request( + :get, + "/v1/billing/meters/#{meter_id}/event_summaries", + params: { + customer: stripe_customer_id, + start_time: start_time.to_i, + end_time: end_time.to_i + } + ) + + # Handle response - it can be an Array directly or a Hash with 'data' + summaries = if response.is_a?(Array) + response + elsif response.is_a?(Hash) && response['data'] + response['data'] + end + + if summaries + # Calculate total usage from summaries + total_usage = 0 + usage_by_day = {} + + summaries.each do |summary| + next unless summary.is_a?(Hash) + + value = summary['aggregated_value'].to_i + total_usage += value + + # Group by day if period info available + next unless summary['period'] && summary['period']['start'] + + day = Time.at(summary['period']['start']).strftime('%Y-%m-%d') + usage_by_day[day] ||= 0 + usage_by_day[day] += value + end + + # Get current balance from credit service + credit_service = Enterprise::Billing::V2::CreditManagementService.new(account: account) + balance = credit_service.credit_balance + + # For feature breakdown, we'll need to track this locally + # since meter summaries don't include feature metadata + usage_by_feature = fetch_local_feature_breakdown(start_time, end_time) + + { + success: true, + total_usage: total_usage, + credits_remaining: balance[:total], + period_start: start_time, + period_end: end_time, + usage_by_feature: usage_by_feature, + usage_by_day: usage_by_day, + event_count: summaries.length, + source: 'stripe_meter_events' + } + end + rescue StandardError => e + Rails.logger.error "Failed to fetch Stripe meter summaries: #{e.message}" + nil + end + end + + def fetch_stripe_analytics + # Legacy method - kept for compatibility + fetch_stripe_meter_events + end + + def fetch_local_usage_summary + # Get current month's usage from local credit transactions + end_time = Time.current + start_time = end_time.beginning_of_month + + usage_scope = credit_usage_scope(start_time: start_time, end_time: end_time) + + # Calculate usage from local credit transactions for current month + total_used = usage_scope.sum(:amount) + + # Get usage by feature + usage_by_feature = usage_scope + .group(feature_grouping_clause) + .sum(:amount) + + # Get current credit balance + credit_service = Enterprise::Billing::V2::CreditManagementService.new(account: account) + balance = credit_service.credit_balance + + { + success: true, + total_usage: total_used, + credits_remaining: balance[:total], + period_start: start_time, + period_end: end_time, + usage_by_feature: usage_by_feature, + source: 'local' # Indicate this is from local data + } + end + + def recent_transactions(limit: 10) + account.credit_transactions + .recent + .limit(limit) + end + private + def parse_stripe_analytics(response, start_time, end_time) + return nil unless response + + data = response.is_a?(Hash) ? response : response.data + return nil unless data + + # Sum up all meter events + total_usage = 0 + usage_by_day = {} + + if data['data'].is_a?(Array) + data['data'].each do |event| + value = event['value'] || 0 + total_usage += value + + # Group by day if timestamp available + next unless event['timestamp'] + + day = Time.at(event['timestamp']).strftime('%Y-%m-%d') + usage_by_day[day] ||= 0 + usage_by_day[day] += value + end + end + + # Get current credit balance + credit_service = Enterprise::Billing::V2::CreditManagementService.new(account: account) + balance = credit_service.credit_balance + + { + success: true, + total_usage: total_usage, + credits_remaining: balance[:total], + period_start: start_time, + period_end: end_time, + usage_by_day: usage_by_day, + source: 'stripe_analytics' + } + end + + def credit_usage_scope(start_time:, end_time:) + account.credit_transactions + .where(transaction_type: 'use', created_at: start_time..end_time) + end + + def feature_grouping_clause + Arel.sql("COALESCE(metadata->>'feature', 'unattributed')") + end + + def fetch_local_feature_breakdown(start_time, end_time) + # Get feature breakdown from local transactions + # This is needed because meter summaries don't include metadata + account.credit_transactions + .where(transaction_type: 'use', created_at: start_time..end_time) + .group(feature_grouping_clause) + .sum(:amount) + end + def stripe_customer_id custom_attribute('stripe_customer_id') end diff --git a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb index 067d0b1e0..c0647a30b 100644 --- a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb @@ -9,54 +9,38 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B ) end - def report(credits_used, feature, _metadata: {}) + def report(credits_used, feature, _metadata = {}) return { success: false, message: 'Usage reporting disabled' } unless should_report_usage? - with_stripe_error_handling do - payload = build_meter_payload(credits_used, feature) + identifier = usage_identifier(feature) - response = stripe_client.execute_request( - :post, - '/v1/billing/meter_events', - headers: { 'Idempotency-Key' => payload[:identifier] }, - params: payload - ) + # Stripe V2 meter events API + response, _api_key = stripe_client.execute_request( + :post, + '/v1/billing/meter_events', + headers: { 'Stripe-Version' => '2025-08-27.preview' }, + params: { + :event_name => meter_event_name, + 'payload[value]' => credits_used.to_s, + 'payload[stripe_customer_id]' => stripe_customer_id, + :identifier => identifier + } + ) - response_data = extract_response_data(response) - event_id = response_data['id'] || payload[:identifier] + event_id = response.data[:identifier] + Rails.logger.info "Usage reported: #{credits_used} credits for #{feature} (#{event_id})" - log_usage_payload(credits_used, feature, payload) - - { success: true, event_id: event_id, reported_credits: credits_used } - end + { success: true, event_id: event_id, reported_credits: credits_used } + rescue Stripe::StripeError => e + Rails.logger.error "Stripe usage reporting failed: #{e.message}" + { success: false, message: e.message } rescue StandardError => e - Rails.logger.error "Failed to report usage for account #{account.id}: #{e.message}" + Rails.logger.error "Usage reporting error: #{e.message}" { success: false, message: e.message } end private - # No HTTP client; use Stripe gem's generic execute_request to support preview endpoints - - def build_meter_payload(credits_used, feature) - { - identifier: usage_identifier(feature), - event_name: meter_event_name, - timestamp: Time.current.to_i, - payload: { - value: credits_used, - stripe_customer_id: stripe_customer_id, - feature: feature - } - } - end - - def log_usage_payload(credits_used, feature, payload) - Rails.logger.info "Usage Reported: #{credits_used} credits for account #{account.id} (#{feature})" - Rails.logger.info "Stripe meter ID: #{v2_config[:meter_id]}" - Rails.logger.debug { "Stripe meter event payload: #{payload}" } - end - def should_report_usage? v2_enabled? && stripe_customer_id.present? && @@ -65,7 +49,8 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B end def meter_event_name - v2_config[:meter_event_name].presence || "captain_prompts_#{Rails.env}" + # Use the exact event name configured in the meter + ENV['STRIPE_V2_METER_EVENT_NAME'] || v2_config[:meter_event_name].presence || 'ai_prompts' end def usage_identifier(feature) @@ -75,11 +60,4 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B def stripe_customer_id custom_attribute('stripe_customer_id') end - - def extract_response_data(response) - return response if response.is_a?(Hash) - return response.data if response.respond_to?(:data) - - {} - end end diff --git a/setup_stripe_v2_billing.rb b/setup_stripe_v2_billing.rb deleted file mode 100755 index 57587a64c..000000000 --- a/setup_stripe_v2_billing.rb +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env ruby - -require 'stripe' -require 'httparty' -require 'optparse' -require 'json' - -# Provision core V2 Billing objects using Stripe Ruby client. - -class StripeV2Provisioner - def initialize(api_key:, api_version: ENV.fetch('STRIPE_API_VERSION', '2024-09-30.acacia')) - Stripe.api_key = api_key - Stripe.api_version = api_version - @client = Stripe::StripeClient.new(api_key: api_key) - @api_key = api_key - @api_version = api_version - end - - def run! - meter = ensure_meter - results = { meter: meter } - v2 = provision_v2_resources(meter) - results.merge!(v2) if v2 - print_summary(results) - end - - def ensure_meter - find_or_create_meter(env_suffix('captain_prompts'), 'Captain Prompts') - end - - def provision_v2_resources(meter) - resources = create_v2_core(meter) - attach_and_publish(resources) - resources - rescue StandardError => e - warn "[WARN] V2 provisioning skipped due to: #{e.message}" - nil - end - - def create_v2_core(meter) - cpu = create_custom_pricing_unit - plan = create_pricing_plan - item = create_licensed_item - fee = create_license_fee(item['id']) - svc = create_service_action(cpu['id']) - mi = create_metered_item(meter['id']) - rc = create_rate_card - add_rate(rc['id'], mi['id'], cpu['id']) - { cpu: cpu, plan: plan, licensed_item: item, license_fee: fee, service_action: svc, metered_item: mi, rate_card: rc } - end - - def attach_and_publish(res) - attach_components(res[:plan]['id'], res[:license_fee], res[:service_action], res[:rate_card]) - publish_plan(res[:plan]['id']) - end - - def create_custom_pricing_unit - post('/v2/billing/custom_pricing_units', display_name: 'Captain Credits', lookup_key: env_suffix('captain_credits')) - end - - def find_or_create_meter(event_name, display_name) - list = get('/v1/billing/meters', limit: 100) - arr = list.is_a?(Hash) ? (list['data'] || []) : Array(list) - existing = arr.find { |m| (m['event_name'] || m[:event_name]) == event_name } - return existing if existing - - post('/v1/billing/meters', - display_name: display_name, - event_name: event_name, - default_aggregation: { formula: 'sum' }, - customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id' }, - value_settings: { event_payload_key: 'value' }) - end - - def create_pricing_plan - post('/v2/billing/pricing_plans', display_name: env_suffix('Chatwoot Business Plan'), currency: 'usd', tax_behavior: 'exclusive') - end - - def create_licensed_item - post('/v2/billing/licensed_items', display_name: 'Business Agent Seat', lookup_key: env_suffix('business_agent'), unit_label: 'per agent') - end - - def create_license_fee(licensed_item_id) - post('/v2/billing/license_fees', display_name: 'Business Monthly Fee', currency: 'usd', service_interval: 'month', service_interval_count: 1, - tax_behavior: 'exclusive', unit_amount: '3900', licensed_item: licensed_item_id) - end - - def create_service_action(cpu_id) - post( - '/v2/billing/service_actions', - lookup_key: env_suffix('business_monthly_credits'), - service_interval: 'month', - service_interval_count: 1, - type: 'credit_grant', - credit_grant: { - name: 'Business Monthly Credits', - amount: { - type: 'custom_pricing_unit', - custom_pricing_unit: { id: cpu_id, value: '2000' } - }, - expiry_config: { type: 'end_of_service_period' }, - applicability_config: { scope: { price_type: 'metered' } } - } - ) - end - - def create_metered_item(meter_id) - post('/v2/billing/metered_items', display_name: 'Captain Prompt', lookup_key: env_suffix('captain_prompt'), meter: meter_id) - end - - def create_rate_card - post( - '/v2/billing/rate_cards', - display_name: env_suffix('Chatwoot Usage Rates'), - currency: 'usd', - service_interval: 'month', - service_interval_count: 1, - tax_behavior: 'exclusive' - ) - end - - def add_rate(rate_card_id, metered_item_id, cpu_id) - post("/v2/billing/rate_cards/#{rate_card_id}/rates", metered_item: metered_item_id, custom_pricing_unit_amount: { id: cpu_id, value: '1' }) - end - - def attach_components(plan_id, license_fee, service_action, rate_card) - post( - "/v2/billing/pricing_plans/#{plan_id}/components", - type: 'license_fee', - license_fee: { id: license_fee['id'], version: license_fee['latest_version'] } - ) - post( - "/v2/billing/pricing_plans/#{plan_id}/components", - type: 'credit_grant', - service_action: { id: service_action['id'] } - ) - post( - "/v2/billing/pricing_plans/#{plan_id}/components", - type: 'rate_card', - rate_card: { id: rate_card['id'], version: rate_card['latest_version'] } - ) - end - - def publish_plan(plan_id) - post("/v2/billing/pricing_plans/#{plan_id}", live_version: 'latest') - end - - def print_summary(results) - puts "\n--- Stripe V2 IDs ---" - puts JSON.pretty_generate(StripeV2Printer.build_summary(results)) - puts "\nEnvironment variables to set:" - puts 'STRIPE_V2_ENABLED=true' - StripeV2Printer.print_env_vars(results, env_suffix('captain_prompts')) - puts "STRIPE_API_VERSION=#{Stripe.api_version}" - end - - private - - def env_suffix(base) - env = ENV.fetch('RAILS_ENV', 'development') - "#{base}_#{env}" - end - - def post(path, **params) - if path.start_with?('/v2') - http_post_v2(path, params) - else - # Use Stripe gem for v1 endpoints - @client.execute_request(:post, path, params: params) - end - end - - def get(path, **params) - url = "https://api.stripe.com#{path}" - headers = { 'Authorization' => "Bearer #{@api_key}" } - response = HTTParty.get(url, headers: headers, query: params) - raise(StandardError, response.parsed_response) unless response.success? - - response.parsed_response - end - - def http_post_v2(path, params) - url = "https://api.stripe.com#{path}" - headers = { - 'Authorization' => "Bearer #{@api_key}", - 'Stripe-Version' => @api_version, - 'Stripe-Api-Version' => @api_version, - 'Content-Type' => 'application/json' - } - response = HTTParty.post(url, headers: headers, body: JSON.generate(params)) - raise(StandardError, response.parsed_response) unless response.success? - - response.parsed_response - end -end - -module StripeV2Printer - module_function - - def build_summary(results) - mapping = { - meter: :meter_id, - cpu: :cpu_id, - plan: :plan_id, - licensed_item: :licensed_item_id, - license_fee: :license_fee_id, - service_action: :service_action_id, - metered_item: :metered_item_id, - rate_card: :rate_card_id - } - mapping.each_with_object({}) do |(key, out_key), acc| - obj = results[key] - id = obj && obj['id'] - acc[out_key] = id if id - end - end - - def print_env_vars(results, event_name) - meter_id = results[:meter] && results[:meter]['id'] - plan_id = results[:plan] && results[:plan]['id'] - if meter_id - puts "STRIPE_V2_METER_ID=#{meter_id}" - puts "STRIPE_V2_METER_EVENT_NAME=#{event_name}" - end - puts "STRIPE_V2_BUSINESS_PLAN_ID=#{plan_id}" if plan_id - end -end - -if __FILE__ == $PROGRAM_NAME - options = {} - OptionParser.new do |opts| - opts.banner = 'Usage: ruby setup_stripe_v2_billing.rb --key sk_test_xxx [--version 2025-08-27.preview]' - opts.on('-k', '--key KEY', 'Stripe secret key') { |v| options[:key] = v } - opts.on('-v', '--version VERSION', 'Stripe API version') { |v| options[:version] = v } - end.parse! - - key = options[:key] || ENV.fetch('STRIPE_SECRET_KEY', nil) - abort 'Missing STRIPE_SECRET_KEY' if key.to_s.strip.empty? - - StripeV2Provisioner.new(api_key: key, api_version: options[:version]).run! -end