stripe v2 fix meter usage

This commit is contained in:
Tanmay Deep Sharma
2025-10-08 23:29:31 +02:00
parent 01cd7b878d
commit b9168664f7
6 changed files with 450 additions and 332 deletions

View File

@@ -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 = {

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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