code clean up

This commit is contained in:
Tanmay Deep Sharma
2025-10-09 00:37:55 +02:00
parent b9168664f7
commit 708296a746
4 changed files with 334 additions and 311 deletions
+1 -1
View File
@@ -901,7 +901,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
stripe (16.0.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -3,139 +3,53 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
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 }
)
grants = Stripe::Billing::CreditGrant.list(
{ customer: stripe_customer_id, limit: 100 },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
parse_credit_grants(response)
end
rescue StandardError => e
Rails.logger.error "Failed to fetch credit grants: #{e.message}"
nil
parse_credit_grants(grants)
end
def create_stripe_credit_grant(amount, type: 'promotional', metadata: {})
return nil unless stripe_customer_id.present?
return nil if stripe_customer_id.blank?
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
params = build_credit_grant_params(amount, type, metadata)
create_stripe_grant(params)
end
def grant_monthly_credits(amount = 2000, metadata: {})
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?
log_credit_transaction(
type: 'grant',
amount: amount,
credit_type: 'monthly',
description: 'Monthly credit grant',
metadata: base_metadata(metadata).merge(
'expired_amount' => expired_amount,
'stripe_grant_id' => grant_id
)
)
end
log_monthly_grant(amount, expired_amount, stripe_grant&.id, metadata) if amount.positive?
{ success: true, granted: amount, expired: expired_amount, remaining: total_credits }
end
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Failed to grant monthly credits: #{e.message}"
{ success: false, message: e.message }
end
# rubocop:disable Metrics/MethodLength
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)
stripe_result = report_usage_to_stripe(amount, feature, metadata)
return { success: false, message: "Usage reporting failed: #{stripe_result[:message]}" } unless stripe_result[:success]
with_locked_account do
current_balance = credit_balance
return { success: false, message: 'Insufficient credits' } unless sufficient_balance?(amount)
if current_balance[:total] < amount
Rails.logger.warn 'Local cache out of sync with Stripe'
return { success: false, message: 'Insufficient credits' }
end
credit_type = deduct_credits(amount)
log_credit_usage(amount, feature, credit_type, stripe_result[:event_id], metadata)
current_monthly = monthly_credits
current_topup = topup_credits
credit_type = 'monthly'
if current_monthly >= amount
update_credits(monthly: current_monthly - amount)
else
monthly_used = current_monthly
topup_used = amount - monthly_used
update_credits(monthly: 0, topup: current_topup - topup_used)
credit_type = monthly_used.positive? ? 'mixed' : 'topup'
end
log_credit_transaction(
type: 'use',
amount: amount,
credit_type: credit_type,
description: "Used for #{feature}",
metadata: base_metadata(metadata).merge(
'feature' => feature,
'stripe_event_id' => stripe_result[:event_id]
)
)
final_balance = credit_balance
{
success: true,
credits_used: amount,
remaining: final_balance[:total],
source: final_balance[:source],
stripe_event_id: stripe_result[:event_id]
}
build_credit_usage_result(amount, stripe_result[:event_id])
end
end
# rubocop:enable Metrics/MethodLength
def add_topup_credits(amount, metadata: {})
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
grant_id = stripe_grant&.id
new_balance = topup_credits + amount
update_credits(topup: new_balance)
@@ -150,10 +64,6 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
{ success: true, topup_balance: new_balance, total: total_credits }
end
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Failed to add topup credits: #{e.message}"
{ success: false, message: e.message }
end
def total_credits
@@ -165,33 +75,9 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
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
calculate_balance_from_stripe(stripe_usage, initial_credits)
else
{
monthly: monthly_credits,
topup: topup_credits,
total: total_credits,
last_synced: Time.current,
source: 'local_fallback'
}
local_fallback_balance
end
end
@@ -211,23 +97,19 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
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: {
summaries = Stripe::Billing::Meter.list_event_summaries(
ENV.fetch('STRIPE_V2_METER_ID', nil),
{
customer: stripe_customer_id,
start_time: Time.current.beginning_of_month.to_i,
end_time: Time.current.to_i
}
},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
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}"
summaries_data = extract_summaries(summaries)
summaries_data.sum { |s| (s['aggregated_value'] || s[:aggregated_value] || 0).to_i }
rescue StandardError
nil
end
@@ -260,62 +142,21 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
end
def parse_credit_grants(response)
return nil unless response
grants = extract_grants_from_response(response)
return nil if grants.blank?
data = response.is_a?(Stripe::StripeResponse) ? response.data : response
return nil unless data
totals = { monthly: 0, topup: 0, grant_details: [] }
process_grants(grants, totals)
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
}
build_credit_grant_summary(totals)
end
def extract_grant_amount(amount_data)
amount_type = amount_data['type'] || amount_data[:type]
return 0 unless amount_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
value_data = amount_data[amount_type] || amount_data[amount_type.to_sym]
extract_value_from_data(value_data)
end
def sync_local_balance_from_stripe(stripe_balance)
@@ -345,5 +186,184 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
def base_metadata(metadata)
metadata.is_a?(Hash) ? metadata.stringify_keys : {}
end
# Helper methods for create_stripe_credit_grant
def build_credit_grant_params(amount, type, metadata)
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'
params
end
def create_stripe_grant(params)
Stripe::Billing::CreditGrant.create(
params,
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
end
# Helper methods for grant_monthly_credits
def log_monthly_grant(amount, expired_amount, grant_id, metadata)
log_credit_transaction(
type: 'grant',
amount: amount,
credit_type: 'monthly',
description: 'Monthly credit grant',
metadata: base_metadata(metadata).merge('expired_amount' => expired_amount, 'stripe_grant_id' => grant_id)
)
end
# Helper methods for use_credit
def report_usage_to_stripe(amount, feature, metadata)
reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account)
reporter.report(amount, feature, metadata)
end
def sufficient_balance?(amount)
current_balance = credit_balance
current_balance[:total] >= amount
end
def deduct_credits(amount)
current_monthly = monthly_credits
current_topup = topup_credits
if current_monthly >= amount
update_credits(monthly: current_monthly - amount)
'monthly'
else
monthly_used = current_monthly
topup_used = amount - monthly_used
update_credits(monthly: 0, topup: current_topup - topup_used)
monthly_used.positive? ? 'mixed' : 'topup'
end
end
def log_credit_usage(amount, feature, credit_type, event_id, metadata)
log_credit_transaction(
type: 'use',
amount: amount,
credit_type: credit_type,
description: "Used for #{feature}",
metadata: base_metadata(metadata).merge('feature' => feature, 'stripe_event_id' => event_id)
)
end
def build_credit_usage_result(amount, event_id)
final_balance = credit_balance
{
success: true,
credits_used: amount,
remaining: final_balance[:total],
source: final_balance[:source],
stripe_event_id: event_id
}
end
# Helper methods for parse_credit_grants
def extract_grants_from_response(response)
return nil unless response
data = response.is_a?(Stripe::StripeResponse) ? response.data : response
return nil unless data
data.is_a?(Hash) ? (data['data'] || data[:data] || []) : []
end
def process_grants(grants, totals)
grants.each do |grant|
next unless grant_active?(grant)
amount_data = grant['amount'] || grant[:amount]
next unless amount_data
process_single_grant(grant, amount_data, totals)
end
end
def grant_active?(grant)
voided_at = grant['voided_at'] || grant[:voided_at]
voided_at.nil?
end
def process_single_grant(grant, amount_data, totals)
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?
totals[:topup] += available
totals[:grant_details] << { type: 'topup', amount: available, id: grant_id }
else
totals[:monthly] += available
totals[:grant_details] << { type: 'monthly', amount: available, id: grant_id, expiry_config: expiry_config }
end
end
def build_credit_grant_summary(totals)
{
monthly: totals[:monthly],
topup: totals[:topup],
total: totals[:monthly] + totals[:topup],
last_synced: Time.current,
source: 'stripe',
grant_details: totals[:grant_details]
}
end
# Helper methods for extract_grant_amount
def extract_value_from_data(value_data)
return 0 unless value_data
(value_data['value'] || value_data[:value] || 0).to_i
end
# Helper methods for credit_balance
def calculate_balance_from_stripe(stripe_usage, 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 = build_stripe_balance(monthly_portion, topup_portion, remaining, total_used, total_granted)
sync_local_balance_from_stripe(balance)
balance
end
def build_stripe_balance(monthly_portion, topup_portion, remaining, total_used, total_granted)
{
monthly: monthly_portion,
topup: topup_portion,
total: remaining,
usage_from_stripe: total_used,
granted_from_stripe: total_granted,
last_synced: Time.current,
source: 'stripe'
}
end
def local_fallback_balance
{
monthly: monthly_credits,
topup: topup_credits,
total: total_credits,
last_synced: Time.current,
source: 'local_fallback'
}
end
end
# rubocop:enable Metrics/ClassLength
@@ -2,15 +2,11 @@ class Enterprise::Billing::V2::UsageAnalyticsService < Enterprise::Billing::V2::
def fetch_usage_summary
return { success: false, message: 'Not on V2 billing' } unless v2_enabled?
# ALWAYS use Stripe as primary source
stripe_analytics = fetch_stripe_meter_events
if stripe_analytics && stripe_analytics[:success]
Rails.logger.info 'Using Stripe meter events as source of truth'
return stripe_analytics
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
@@ -23,67 +19,12 @@ class Enterprise::Billing::V2::UsageAnalyticsService < Enterprise::Billing::V2::
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
}
)
summaries = fetch_meter_summaries_from_stripe(start_time, end_time)
return nil unless summaries
# 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}"
build_usage_analytics_result(summaries, start_time, end_time)
rescue StandardError
nil
end
end
@@ -134,40 +75,13 @@ class Enterprise::Billing::V2::UsageAnalyticsService < Enterprise::Billing::V2::
def parse_stripe_analytics(response, start_time, end_time)
return nil unless response
data = response.is_a?(Hash) ? response : response.data
data = extract_data_from_response(response)
return nil unless data
# Sum up all meter events
total_usage = 0
usage_by_day = {}
metrics = calculate_usage_metrics_from_events(data)
balance = fetch_credit_balance
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'
}
build_analytics_result(metrics, balance, start_time, end_time)
end
def credit_usage_scope(start_time:, end_time:)
@@ -191,4 +105,102 @@ class Enterprise::Billing::V2::UsageAnalyticsService < Enterprise::Billing::V2::
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
# Helper methods for fetch_stripe_meter_events
def fetch_meter_summaries_from_stripe(start_time, end_time)
meter_id = ENV.fetch('STRIPE_V2_METER_ID', nil)
response = Stripe::Billing::Meter.list_event_summaries(
meter_id,
{ customer: stripe_customer_id, start_time: start_time.to_i, end_time: end_time.to_i },
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
extract_summaries_from_response(response)
end
def extract_summaries_from_response(response)
return response if response.is_a?(Array)
return response.data if response.respond_to?(:data)
return response['data'] if response.is_a?(Hash) && response['data']
nil
end
def build_usage_analytics_result(summaries, start_time, end_time)
metrics = calculate_usage_metrics(summaries)
balance = fetch_credit_balance
usage_by_feature = fetch_local_feature_breakdown(start_time, end_time)
{
success: true,
total_usage: metrics[:total_usage],
credits_remaining: balance[:total],
period_start: start_time,
period_end: end_time,
usage_by_feature: usage_by_feature,
usage_by_day: metrics[:usage_by_day],
event_count: summaries.length,
source: 'stripe_meter_events'
}
end
def calculate_usage_metrics(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
next unless summary['period']&.[]('start')
day = Time.zone.at(summary['period']['start']).strftime('%Y-%m-%d')
usage_by_day[day] ||= 0
usage_by_day[day] += value
end
{ total_usage: total_usage, usage_by_day: usage_by_day }
end
def fetch_credit_balance
Enterprise::Billing::V2::CreditManagementService.new(account: account).credit_balance
end
# Helper methods for parse_stripe_analytics
def extract_data_from_response(response)
response.is_a?(Hash) ? response : response.data
end
def calculate_usage_metrics_from_events(data)
total_usage = 0
usage_by_day = {}
return { total_usage: total_usage, usage_by_day: usage_by_day } unless data['data'].is_a?(Array)
data['data'].each do |event|
value = event['value'] || 0
total_usage += value
next unless event['timestamp']
day = Time.zone.at(event['timestamp']).strftime('%Y-%m-%d')
usage_by_day[day] ||= 0
usage_by_day[day] += value
end
{ total_usage: total_usage, usage_by_day: usage_by_day }
end
def build_analytics_result(metrics, balance, start_time, end_time)
{
success: true,
total_usage: metrics[:total_usage],
credits_remaining: balance[:total],
period_start: start_time,
period_end: end_time,
usage_by_day: metrics[:usage_by_day],
source: 'stripe_analytics'
}
end
end
@@ -12,30 +12,9 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
def report(credits_used, feature, _metadata = {})
return { success: false, message: 'Usage reporting disabled' } unless should_report_usage?
identifier = usage_identifier(feature)
# 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
}
)
event_id = response.data[:identifier]
Rails.logger.info "Usage reported: #{credits_used} credits for #{feature} (#{event_id})"
{ 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 }
meter_event = create_meter_event(credits_used, feature)
{ success: true, event_id: meter_event.identifier, reported_credits: credits_used }
rescue StandardError => e
Rails.logger.error "Usage reporting error: #{e.message}"
{ success: false, message: e.message }
end
@@ -60,4 +39,16 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
def create_meter_event(credits_used, feature)
identifier = usage_identifier(feature)
Stripe::Billing::MeterEvent.create(
{
event_name: meter_event_name,
payload: { value: credits_used.to_s, stripe_customer_id: stripe_customer_id },
identifier: identifier
},
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
)
end
end