cleanup code
This commit is contained in:
@@ -1,48 +1,63 @@
|
||||
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
|
||||
def fetch_stripe_credit_balance
|
||||
sync_service.fetch_stripe_credit_balance
|
||||
end
|
||||
|
||||
def create_stripe_credit_grant(amount, type: 'promotional', metadata: {})
|
||||
sync_service.create_stripe_credit_grant(amount, type: type, metadata: metadata)
|
||||
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)
|
||||
update_credits(monthly: amount)
|
||||
log_monthly_grant(amount, expired_amount, stripe_grant&.id, metadata) if amount.positive?
|
||||
{ success: true, granted: amount, expired: expired_amount, remaining: total_credits }
|
||||
end
|
||||
end
|
||||
|
||||
def use_credit(feature: 'ai_captain', amount: 1, metadata: {})
|
||||
# Core method: Use credits and report to Stripe
|
||||
def use_credit(feature: 'ai_captain', amount: 1)
|
||||
return { success: true, credits_used: 0, remaining: total_credits } if amount <= 0
|
||||
|
||||
with_locked_account do
|
||||
return { success: false, message: 'Insufficient credits' } unless sufficient_balance?(amount)
|
||||
return { success: false, message: 'Insufficient credits' } unless total_credits >= amount
|
||||
|
||||
stripe_result = report_usage_to_stripe(amount, feature, metadata)
|
||||
# Report usage to Stripe for billing
|
||||
stripe_result = report_usage_to_stripe(amount, feature)
|
||||
return { success: false, message: "Usage reporting failed: #{stripe_result[:message]}" } unless stripe_result[:success]
|
||||
|
||||
credit_type = deduct_credits(amount)
|
||||
log_credit_usage(amount, feature, credit_type, stripe_result[:event_id], metadata)
|
||||
build_credit_usage_result(amount, stripe_result[:event_id])
|
||||
# Deduct credits locally
|
||||
deduct_credits(amount)
|
||||
|
||||
# Log the transaction
|
||||
log_credit_transaction(
|
||||
type: 'use',
|
||||
amount: amount,
|
||||
credit_type: determine_credit_type(amount),
|
||||
description: "Used for #{feature}"
|
||||
)
|
||||
|
||||
{ success: true, credits_used: amount, remaining: total_credits }
|
||||
end
|
||||
end
|
||||
|
||||
def add_topup_credits(amount, metadata: {})
|
||||
# Webhook handlers - just sync what Stripe tells us
|
||||
def sync_monthly_credits(amount)
|
||||
with_locked_account do
|
||||
update_credits(monthly: amount)
|
||||
if amount.positive?
|
||||
log_credit_transaction(
|
||||
type: 'grant',
|
||||
amount: amount,
|
||||
credit_type: 'monthly',
|
||||
description: 'Monthly credits from Stripe'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_monthly_expired
|
||||
with_locked_account do
|
||||
expired = monthly_credits
|
||||
update_credits(monthly: 0) if expired.positive?
|
||||
expired
|
||||
end
|
||||
end
|
||||
|
||||
def add_topup_credits(amount)
|
||||
with_locked_account do
|
||||
stripe_grant = create_stripe_credit_grant(amount, type: 'topup', metadata: metadata)
|
||||
new_balance = topup_credits + amount
|
||||
update_credits(topup: new_balance)
|
||||
log_credit_transaction(
|
||||
type: 'topup', amount: amount, credit_type: 'topup',
|
||||
description: 'Topup credits added',
|
||||
metadata: base_metadata(metadata).merge('stripe_grant_id' => stripe_grant&.id)
|
||||
type: 'topup',
|
||||
amount: amount,
|
||||
credit_type: 'topup',
|
||||
description: 'Topup credits added'
|
||||
)
|
||||
{ success: true, topup_balance: new_balance, total: total_credits }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,71 +65,11 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
monthly_credits + topup_credits
|
||||
end
|
||||
|
||||
def credit_balance
|
||||
stripe_usage = sync_service.fetch_stripe_usage_total
|
||||
initial_credits = initial_credits_from_local
|
||||
if stripe_usage.is_a?(Numeric) && initial_credits
|
||||
balance = sync_service.calculate_balance_from_stripe(stripe_usage, initial_credits)
|
||||
sync_local_balance_from_stripe(balance)
|
||||
balance
|
||||
else
|
||||
sync_service.local_fallback_balance
|
||||
end
|
||||
end
|
||||
|
||||
def expire_monthly_credits(metadata: {})
|
||||
with_locked_account do
|
||||
expired_amount = expire_current_monthly_credits(metadata: metadata)
|
||||
{ success: true, expired: expired_amount, remaining: total_credits }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_service
|
||||
@sync_service ||= Enterprise::Billing::V2::StripeCreditSyncService.new(account: account)
|
||||
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 expire_current_monthly_credits(metadata: {})
|
||||
current_monthly = monthly_credits
|
||||
return 0 if current_monthly.zero?
|
||||
|
||||
update_credits(monthly: 0)
|
||||
log_credit_transaction(
|
||||
type: 'expire', amount: current_monthly, credit_type: 'monthly',
|
||||
description: 'Monthly credits expired', metadata: base_metadata(metadata)
|
||||
)
|
||||
current_monthly
|
||||
end
|
||||
|
||||
def base_metadata(metadata)
|
||||
metadata.is_a?(Hash) ? metadata.stringify_keys : {}
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def report_usage_to_stripe(amount, feature, metadata)
|
||||
def report_usage_to_stripe(amount, feature)
|
||||
reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account)
|
||||
reporter.report(amount, feature, metadata)
|
||||
end
|
||||
|
||||
def sufficient_balance?(amount)
|
||||
# Use local balance (real-time) instead of Stripe balance (5-10 min delay)
|
||||
total_credits >= amount
|
||||
reporter.report(amount, feature)
|
||||
end
|
||||
|
||||
def deduct_credits(amount)
|
||||
@@ -123,40 +78,20 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
|
||||
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)
|
||||
# Use local balance (already deducted) instead of fetching from Stripe
|
||||
{
|
||||
success: true,
|
||||
credits_used: amount,
|
||||
remaining: total_credits,
|
||||
source: 'local',
|
||||
stripe_event_id: event_id
|
||||
}
|
||||
end
|
||||
|
||||
def sync_local_balance_from_stripe(stripe_balance)
|
||||
update_credits(
|
||||
monthly: stripe_balance[:monthly],
|
||||
topup: stripe_balance[:topup]
|
||||
)
|
||||
def determine_credit_type(amount)
|
||||
if monthly_credits >= amount
|
||||
'monthly'
|
||||
elsif monthly_credits.positive?
|
||||
'mixed'
|
||||
else
|
||||
'topup'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
class Enterprise::Billing::V2::StripeCreditSyncService < Enterprise::Billing::V2::BaseService
|
||||
def fetch_stripe_credit_balance
|
||||
return nil unless stripe_customer_id.present? && v2_enabled?
|
||||
|
||||
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(grants)
|
||||
end
|
||||
|
||||
def fetch_stripe_usage_total
|
||||
return nil unless stripe_customer_id.present? && ENV['STRIPE_V2_METER_ID'].present?
|
||||
|
||||
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' }
|
||||
)
|
||||
|
||||
summaries_data = extract_summaries(summaries)
|
||||
summaries_data.sum { |s| (s['aggregated_value'] || s[:aggregated_value] || 0).to_i }
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def create_stripe_credit_grant(amount, type: 'promotional', metadata: {})
|
||||
return nil if stripe_customer_id.blank?
|
||||
|
||||
params = build_credit_grant_params(amount, type, metadata)
|
||||
create_stripe_grant(params)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
{
|
||||
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: monthly_credits + topup_credits,
|
||||
last_synced: Time.current,
|
||||
source: 'local_fallback'
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stripe_customer_id
|
||||
custom_attribute('stripe_customer_id')
|
||||
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 parse_credit_grants(response)
|
||||
grants = extract_grants_from_response(response)
|
||||
return nil if grants.blank?
|
||||
|
||||
totals = { monthly: 0, topup: 0, grant_details: [] }
|
||||
process_grants(grants, totals)
|
||||
|
||||
build_credit_grant_summary(totals)
|
||||
end
|
||||
|
||||
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 extract_grant_amount(amount_data)
|
||||
amount_type = amount_data['type'] || amount_data[:type]
|
||||
return 0 unless amount_type
|
||||
|
||||
value_data = amount_data[amount_type] || amount_data[amount_type.to_sym]
|
||||
extract_value_from_data(value_data)
|
||||
end
|
||||
|
||||
def extract_value_from_data(value_data)
|
||||
return 0 unless value_data
|
||||
|
||||
(value_data['value'] || value_data[:value] || 0).to_i
|
||||
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
|
||||
|
||||
def build_credit_grant_params(amount, type, metadata)
|
||||
{
|
||||
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
|
||||
)
|
||||
}
|
||||
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
|
||||
end
|
||||
@@ -1,9 +1,11 @@
|
||||
class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::V2::Concerns::PaymentIntentHandler
|
||||
|
||||
def subscribe_to_pricing_plan(pricing_plan_id:, customer_id: nil)
|
||||
def subscribe_to_pricing_plan(pricing_plan_id:, customer_id: nil, meter_id: nil, meter_event_name: nil)
|
||||
@pricing_plan_id = pricing_plan_id
|
||||
@customer_id = customer_id || stripe_customer_id
|
||||
@meter_id = meter_id
|
||||
@meter_event_name = meter_event_name
|
||||
|
||||
validate_subscription_params
|
||||
execute_subscription_flow
|
||||
@@ -169,13 +171,18 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V
|
||||
end
|
||||
|
||||
def update_account_subscription_info(pricing_plan)
|
||||
update_custom_attributes(
|
||||
attributes = {
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => @customer_id,
|
||||
'stripe_pricing_plan_id' => @pricing_plan_id,
|
||||
'plan_name' => extract_plan_name(pricing_plan),
|
||||
'subscription_status' => 'active'
|
||||
)
|
||||
}
|
||||
# Store meter configuration if provided
|
||||
attributes['stripe_meter_id'] = @meter_id if @meter_id.present?
|
||||
attributes['stripe_meter_event_name'] = @meter_event_name if @meter_event_name.present?
|
||||
|
||||
update_custom_attributes(attributes)
|
||||
end
|
||||
|
||||
def extract_plan_name(pricing_plan)
|
||||
|
||||
@@ -2,205 +2,31 @@ class Enterprise::Billing::V2::UsageAnalyticsService < Enterprise::Billing::V2::
|
||||
def fetch_usage_summary
|
||||
return { success: false, message: 'Not on V2 billing' } unless v2_enabled?
|
||||
|
||||
stripe_analytics = fetch_stripe_meter_events
|
||||
|
||||
if stripe_analytics && stripe_analytics[:success]
|
||||
stripe_analytics
|
||||
else
|
||||
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
|
||||
|
||||
summaries = fetch_meter_summaries_from_stripe(start_time, end_time)
|
||||
return nil unless summaries
|
||||
|
||||
build_usage_analytics_result(summaries, start_time, end_time)
|
||||
rescue StandardError
|
||||
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
|
||||
transactions = account.credit_transactions
|
||||
.where(transaction_type: 'use', created_at: start_time..end_time)
|
||||
|
||||
{
|
||||
success: true,
|
||||
total_usage: total_used,
|
||||
credits_remaining: balance[:total],
|
||||
total_usage: transactions.sum(:amount),
|
||||
credits_remaining: total_credits,
|
||||
period_start: start_time,
|
||||
period_end: end_time,
|
||||
usage_by_feature: usage_by_feature,
|
||||
source: 'local' # Indicate this is from local data
|
||||
usage_by_feature: transactions.group("metadata->>'feature'").sum(:amount)
|
||||
}
|
||||
end
|
||||
|
||||
def recent_transactions(limit: 10)
|
||||
account.credit_transactions
|
||||
.recent
|
||||
.order(created_at: :desc)
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_stripe_analytics(response, start_time, end_time)
|
||||
return nil unless response
|
||||
|
||||
data = extract_data_from_response(response)
|
||||
return nil unless data
|
||||
|
||||
metrics = calculate_usage_metrics_from_events(data)
|
||||
balance = fetch_credit_balance
|
||||
|
||||
build_analytics_result(metrics, balance, start_time, end_time)
|
||||
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
|
||||
|
||||
# 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'
|
||||
}
|
||||
def total_credits
|
||||
Enterprise::Billing::V2::CreditManagementService.new(account: account).total_credits
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,53 +1,32 @@
|
||||
class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService
|
||||
def report_async(credits_used, feature)
|
||||
return unless should_report_usage?
|
||||
def report(credits_used, _feature)
|
||||
return { success: false, message: 'V2 billing not enabled' } unless v2_enabled?
|
||||
return { success: false, message: 'No Stripe customer' } if stripe_customer_id.blank?
|
||||
|
||||
Enterprise::Billing::ReportUsageJob.perform_later(
|
||||
account_id: account.id,
|
||||
credits_used: credits_used,
|
||||
feature: feature
|
||||
meter_event = Stripe::Billing::MeterEvent.create(
|
||||
{
|
||||
event_name: meter_event_name,
|
||||
payload: {
|
||||
value: credits_used.to_s,
|
||||
stripe_customer_id: stripe_customer_id
|
||||
},
|
||||
identifier: "#{account.id}_#{SecureRandom.hex(8)}"
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def report(credits_used, feature, _metadata = {})
|
||||
return { success: false, message: 'Usage reporting disabled' } unless should_report_usage?
|
||||
|
||||
meter_event = create_meter_event(credits_used, feature)
|
||||
{ success: true, event_id: meter_event.identifier, reported_credits: credits_used }
|
||||
{ success: true, event_id: meter_event.identifier }
|
||||
rescue StandardError => e
|
||||
{ success: false, message: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_report_usage?
|
||||
v2_enabled? &&
|
||||
stripe_customer_id.present? &&
|
||||
meter_event_name.present?
|
||||
end
|
||||
|
||||
def meter_event_name
|
||||
# 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)
|
||||
"acct_#{account.id}_#{feature}_#{SecureRandom.hex(8)}"
|
||||
end
|
||||
|
||||
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' }
|
||||
)
|
||||
def meter_event_name
|
||||
custom_attribute('stripe_meter_event_name') || 'ai_prompts'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::BaseService
|
||||
def process(event)
|
||||
Rails.logger.info "Processing V2 billing event: #{event.type}"
|
||||
|
||||
case event.type
|
||||
when 'billing.credit_grant.created'
|
||||
handle_credit_grant_created(event)
|
||||
when 'billing.credit_grant.expired'
|
||||
handle_credit_grant_expired(event)
|
||||
when 'invoice.payment_succeeded'
|
||||
handle_payment_succeeded(event)
|
||||
when 'invoice.payment_failed'
|
||||
handle_payment_failed(event)
|
||||
else
|
||||
Rails.logger.info "Event type not handled: #{event.type}"
|
||||
{ success: true }
|
||||
end
|
||||
rescue StandardError => e
|
||||
@@ -23,97 +16,40 @@ class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::
|
||||
private
|
||||
|
||||
def handle_credit_grant_created(event)
|
||||
return { success: true } if processed_event?(event.id)
|
||||
|
||||
grant = event.data.object
|
||||
amount = extract_credit_amount(grant)
|
||||
amount = extract_amount(grant)
|
||||
return { success: true } if amount.zero?
|
||||
|
||||
metadata = event_metadata(event, grant_id: grant.respond_to?(:id) ? grant.id : nil)
|
||||
credit_service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
|
||||
if monthly_grant?(grant)
|
||||
credit_service.grant_monthly_credits(amount, metadata: metadata)
|
||||
Rails.logger.info "Granted #{amount} monthly credits to account #{account.id}"
|
||||
if grant_expires?(grant)
|
||||
# Monthly grant from Stripe service action
|
||||
credit_service.sync_monthly_credits(amount)
|
||||
else
|
||||
credit_service.add_topup_credits(amount, metadata: metadata.merge('source' => 'credit_grant'))
|
||||
Rails.logger.info "Added #{amount} topup credits to account #{account.id} via credit grant"
|
||||
# Topup grant
|
||||
credit_service.add_topup_credits(amount)
|
||||
end
|
||||
|
||||
{ success: true }
|
||||
end
|
||||
|
||||
def handle_credit_grant_expired(_event)
|
||||
Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits
|
||||
Rails.logger.info "Expired monthly credits for account #{account.id}"
|
||||
expired = Enterprise::Billing::V2::CreditManagementService.new(account: account).sync_monthly_expired
|
||||
Rails.logger.info "Expired #{expired} monthly credits for account #{account.id}"
|
||||
{ success: true }
|
||||
end
|
||||
|
||||
def handle_payment_succeeded(event)
|
||||
return { success: true } if processed_event?(event.id)
|
||||
def extract_amount(grant)
|
||||
return 0 unless grant.respond_to?(:amount)
|
||||
|
||||
invoice = event.data.object
|
||||
|
||||
# Check if this is a topup payment
|
||||
return { success: true } unless invoice_metadata(invoice, 'type') == 'topup'
|
||||
|
||||
credits = invoice_metadata(invoice, 'credits').to_i
|
||||
return { success: true } if credits.zero?
|
||||
|
||||
metadata = event_metadata(event, invoice_id: invoice.id)
|
||||
Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
.add_topup_credits(credits, metadata: metadata.merge('source' => 'invoice'))
|
||||
Rails.logger.info "Added #{credits} topup credits for account #{account.id}"
|
||||
{ success: true }
|
||||
if grant.amount.is_a?(Hash)
|
||||
grant.amount.dig('custom_pricing_unit', 'value').to_i
|
||||
else
|
||||
grant.amount.to_i
|
||||
end
|
||||
end
|
||||
|
||||
def handle_payment_failed(event)
|
||||
invoice = event.data.object
|
||||
Rails.logger.error "Payment failed for account #{account.id}: #{invoice.id}"
|
||||
|
||||
# Update subscription status
|
||||
account.custom_attributes['subscription_status'] = 'past_due'
|
||||
account.save!
|
||||
{ success: true }
|
||||
end
|
||||
|
||||
def processed_event?(event_id)
|
||||
account.credit_transactions.exists?(["metadata ->> 'stripe_event_id' = ?", event_id])
|
||||
end
|
||||
|
||||
def extract_credit_amount(grant)
|
||||
return grant.amount.to_i if grant.respond_to?(:amount) && grant.amount.is_a?(Numeric)
|
||||
|
||||
raw = grant.respond_to?(:amount) ? grant.amount : grant['amount']
|
||||
return 0 if raw.blank?
|
||||
|
||||
return hash_amount_value(raw) if raw.is_a?(Hash) || raw.respond_to?(:[])
|
||||
|
||||
raw.to_i
|
||||
end
|
||||
|
||||
def hash_amount_value(raw)
|
||||
cpu = raw['custom_pricing_unit'] || raw[:custom_pricing_unit]
|
||||
return cpu['value'].to_i if cpu.present?
|
||||
return raw['value'].to_i if raw['value']
|
||||
|
||||
0
|
||||
end
|
||||
|
||||
def monthly_grant?(grant)
|
||||
def grant_expires?(grant)
|
||||
grant.respond_to?(:expires_at) && grant.expires_at.present?
|
||||
end
|
||||
|
||||
def event_metadata(event, extra = {})
|
||||
{ 'stripe_event_id' => event.id }.merge(extra.compact.transform_keys(&:to_s))
|
||||
end
|
||||
|
||||
def invoice_metadata(invoice, key)
|
||||
return unless invoice.respond_to?(:metadata)
|
||||
|
||||
metadata = invoice.metadata
|
||||
return metadata[key] if metadata.respond_to?(:[])
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,57 +5,40 @@ describe Enterprise::Billing::V2::CreditManagementService do
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::ReportUsageJob).to receive(:perform_later)
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_V2_METER_EVENT_NAME', anything).and_return('ai_prompts')
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
allow(ENV).to receive(:[]).with('STRIPE_V2_METER_EVENT_NAME').and_return('ai_prompts')
|
||||
allow(ENV).to receive(:[]).with('STRIPE_V2_METER_ID').and_return(nil) # Disable Stripe meter fetching
|
||||
|
||||
# Stub Stripe credit grant creation
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:create).and_return(
|
||||
OpenStruct.new(id: 'cg_test_123')
|
||||
)
|
||||
|
||||
account.update!(
|
||||
custom_attributes: (account.custom_attributes || {}).merge(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'monthly_credits' => 100,
|
||||
'topup_credits' => 50,
|
||||
'stripe_customer_id' => 'cus_test_123'
|
||||
)
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts'
|
||||
}
|
||||
)
|
||||
|
||||
# Stub Stripe meter event creation
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(
|
||||
OpenStruct.new(identifier: 'test_event_123')
|
||||
)
|
||||
end
|
||||
|
||||
describe '#credit_balance' do
|
||||
it 'returns current credit balance' do
|
||||
balance = service.credit_balance
|
||||
|
||||
expect(balance[:monthly]).to eq(100)
|
||||
expect(balance[:topup]).to eq(50)
|
||||
expect(balance[:total]).to eq(150)
|
||||
describe '#total_credits' do
|
||||
it 'returns sum of monthly and topup credits' do
|
||||
expect(service.total_credits).to eq(150)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#use_credit' do
|
||||
before do
|
||||
# Stub the Stripe meter event creation
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(
|
||||
OpenStruct.new(identifier: 'test_event_123')
|
||||
)
|
||||
end
|
||||
|
||||
context 'when sufficient monthly credits' do
|
||||
it 'uses monthly credits first' do
|
||||
context 'when sufficient credits' do
|
||||
it 'uses credits and reports to Stripe' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 10)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:credits_used]).to eq(10)
|
||||
expect(result[:remaining]).to eq(140)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(90)
|
||||
expect(account.custom_attributes['topup_credits']).to eq(50)
|
||||
expect(account.credit_transactions.order(created_at: :desc).first.metadata['feature']).to eq('ai_test')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -67,27 +50,46 @@ describe Enterprise::Billing::V2::CreditManagementService do
|
||||
expect(result[:message]).to eq('Insufficient credits')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using mixed credits' do
|
||||
it 'uses monthly first then topup' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 120)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(0)
|
||||
expect(account.custom_attributes['topup_credits']).to eq(30)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#grant_monthly_credits' do
|
||||
it 'grants new monthly credits and logs transaction metadata' do
|
||||
expect { service.grant_monthly_credits(500, metadata: { source: 'spec' }) }
|
||||
.to change { account.credit_transactions.count }.by(2) # expire + grant
|
||||
describe '#sync_monthly_credits' do
|
||||
it 'updates monthly credits from Stripe' do
|
||||
service.sync_monthly_credits(500)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(500)
|
||||
expect(account.credit_transactions.order(created_at: :desc).first.metadata['source']).to eq('spec')
|
||||
expect(account.credit_transactions.last.description).to eq('Monthly credits from Stripe')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_monthly_expired' do
|
||||
it 'expires monthly credits' do
|
||||
expired = service.sync_monthly_expired
|
||||
|
||||
expect(expired).to eq(100)
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_topup_credits' do
|
||||
it 'adds topup credits' do
|
||||
expect { service.add_topup_credits(100, metadata: { source: 'spec' }) }
|
||||
.to change { account.credit_transactions.count }.by(1)
|
||||
service.add_topup_credits(100)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['topup_credits']).to eq(150)
|
||||
expect(account.credit_transactions.order(created_at: :desc).first.metadata['source']).to eq('spec')
|
||||
expect(account.credit_transactions.last.description).to eq('Topup credits added')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,17 +16,20 @@ describe Enterprise::Billing::V2::UsageAnalyticsService do
|
||||
|
||||
context 'when account has usage' do
|
||||
before do
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge(
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => 'cus_123'
|
||||
))
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'monthly_credits' => 100,
|
||||
'topup_credits' => 50
|
||||
}
|
||||
)
|
||||
|
||||
account.credit_transactions.create!(
|
||||
transaction_type: 'use',
|
||||
credit_type: 'monthly',
|
||||
amount: 5,
|
||||
description: 'spec monthly',
|
||||
metadata: {},
|
||||
metadata: { 'feature' => 'ai_captain' },
|
||||
created_at: Time.current
|
||||
)
|
||||
|
||||
@@ -35,13 +38,9 @@ describe Enterprise::Billing::V2::UsageAnalyticsService do
|
||||
credit_type: 'topup',
|
||||
amount: 3,
|
||||
description: 'spec topup',
|
||||
metadata: {},
|
||||
metadata: { 'feature' => 'ai_summary' },
|
||||
created_at: Time.current
|
||||
)
|
||||
|
||||
# Stub the Stripe API call to return nil (which will fallback to local data)
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
allow(ENV).to receive(:[]).with('STRIPE_V2_METER_ID').and_return(nil)
|
||||
end
|
||||
|
||||
it 'aggregates usage from credit transactions' do
|
||||
@@ -49,8 +48,29 @@ describe Enterprise::Billing::V2::UsageAnalyticsService do
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:total_usage]).to eq(8)
|
||||
expect(result[:source]).to eq('local')
|
||||
expect(result[:credits_remaining]).to eq(150)
|
||||
expect(result[:usage_by_feature]).to include('ai_captain' => 5, 'ai_summary' => 3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#recent_transactions' do
|
||||
it 'returns recent transactions' do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2 })
|
||||
|
||||
3.times do |i|
|
||||
account.credit_transactions.create!(
|
||||
transaction_type: 'use',
|
||||
amount: i + 1,
|
||||
credit_type: 'monthly',
|
||||
created_at: i.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
transactions = service.recent_transactions(limit: 2)
|
||||
|
||||
expect(transactions.count).to eq(2)
|
||||
expect(transactions.first.amount).to eq(1) # Most recent
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,39 +3,53 @@ require 'rails_helper'
|
||||
describe Enterprise::Billing::V2::UsageReporterService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:config) { Rails.application.config.stripe_v2 }
|
||||
let!(:original_config) { config.deep_dup }
|
||||
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: (account.custom_attributes || {}).merge(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => 'cus_test_123'
|
||||
)
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts'
|
||||
}
|
||||
)
|
||||
|
||||
config[:meter_id] = 'mtr_test_123'
|
||||
config[:meter_event_name] = 'chat_prompts'
|
||||
end
|
||||
|
||||
after { config.replace(original_config) }
|
||||
|
||||
it 'posts usage events to Stripe meters' do
|
||||
meter_event = OpenStruct.new(identifier: 'me_test_123')
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(meter_event)
|
||||
|
||||
# Stub ENV to return the configured meter event name from config
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
allow(ENV).to receive(:[]).with('STRIPE_V2_METER_EVENT_NAME').and_return(nil)
|
||||
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: true, reported_credits: 5)
|
||||
expect(result).to include(success: true, event_id: 'me_test_123')
|
||||
expect(Stripe::Billing::MeterEvent).to have_received(:create) do |params, options|
|
||||
expect(params[:event_name]).to eq('chat_prompts') # Should use the config value
|
||||
expect(params[:event_name]).to eq('ai_prompts')
|
||||
expect(params[:payload][:value]).to eq('5')
|
||||
expect(params[:payload][:stripe_customer_id]).to eq('cus_test_123')
|
||||
expect(options[:stripe_version]).to eq('2025-08-27.preview')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when V2 billing not enabled' do
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 1 })
|
||||
end
|
||||
|
||||
it 'returns error' do
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: false, message: 'V2 billing not enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no Stripe customer' do
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2 })
|
||||
end
|
||||
|
||||
it 'returns error' do
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: false, message: 'No Stripe customer')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,76 +7,61 @@ describe Enterprise::Billing::V2::WebhookHandlerService do
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2 })
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).with(account: account).and_return(credit_service)
|
||||
end
|
||||
|
||||
def build_event(type:, id:, object:)
|
||||
def build_event(type:, object:)
|
||||
data = double('Stripe::Event::Data', object: object)
|
||||
double('Stripe::Event', type: type, id: id, data: data)
|
||||
double('Stripe::Event', type: type, data: data)
|
||||
end
|
||||
|
||||
describe '#process' do
|
||||
context 'when handling monthly credit grant' do
|
||||
it 'grants monthly credits with metadata' do
|
||||
allow(credit_service).to receive(:grant_monthly_credits).and_return(success: true)
|
||||
grant = Struct.new(:id, :amount, :expires_at).new('cg_123', 2000, Time.current)
|
||||
event = build_event(type: 'billing.credit_grant.created', id: 'evt_123', object: grant)
|
||||
it 'syncs monthly credits from Stripe' do
|
||||
allow(credit_service).to receive(:sync_monthly_credits)
|
||||
grant = Struct.new(:amount, :expires_at).new(2000, Time.current)
|
||||
event = build_event(type: 'billing.credit_grant.created', object: grant)
|
||||
|
||||
result = service.process(event)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(credit_service).to have_received(:grant_monthly_credits).with(2000,
|
||||
metadata: hash_including('stripe_event_id' => 'evt_123',
|
||||
'grant_id' => 'cg_123'))
|
||||
expect(credit_service).to have_received(:sync_monthly_credits).with(2000)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling topup credit grant' do
|
||||
it 'adds topup credits' do
|
||||
allow(credit_service).to receive(:add_topup_credits).and_return(success: true)
|
||||
grant = Struct.new(:id, :amount, :expires_at).new('cg_456', 500, nil)
|
||||
event = build_event(type: 'billing.credit_grant.created', id: 'evt_456', object: grant)
|
||||
allow(credit_service).to receive(:add_topup_credits)
|
||||
grant = Struct.new(:amount, :expires_at).new(500, nil)
|
||||
event = build_event(type: 'billing.credit_grant.created', object: grant)
|
||||
|
||||
result = service.process(event)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(credit_service).to have_received(:add_topup_credits)
|
||||
.with(500, metadata: hash_including('stripe_event_id' => 'evt_456', 'grant_id' => 'cg_456', 'source' => 'credit_grant'))
|
||||
expect(credit_service).to have_received(:add_topup_credits).with(500)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invoice payment succeeded for a topup' do
|
||||
it 'adds topup credits from invoice metadata' do
|
||||
allow(credit_service).to receive(:add_topup_credits).and_return(success: true)
|
||||
invoice_metadata = ActiveSupport::HashWithIndifferentAccess.new(type: 'topup', credits: '300')
|
||||
invoice = Struct.new(:id, :metadata).new('in_123', invoice_metadata)
|
||||
event = build_event(type: 'invoice.payment_succeeded', id: 'evt_789', object: invoice)
|
||||
context 'when handling credit expiration' do
|
||||
it 'expires monthly credits' do
|
||||
allow(credit_service).to receive(:sync_monthly_expired).and_return(100)
|
||||
event = build_event(type: 'billing.credit_grant.expired', object: {})
|
||||
|
||||
result = service.process(event)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(credit_service).to have_received(:add_topup_credits)
|
||||
.with(300, metadata: hash_including('stripe_event_id' => 'evt_789', 'invoice_id' => 'in_123', 'source' => 'invoice'))
|
||||
expect(credit_service).to have_received(:sync_monthly_expired)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event already processed' do
|
||||
it 'skips duplicate handling' do
|
||||
account.credit_transactions.create!(
|
||||
transaction_type: 'grant',
|
||||
amount: 100,
|
||||
credit_type: 'monthly',
|
||||
metadata: { 'stripe_event_id' => 'evt_dup' }
|
||||
)
|
||||
allow(credit_service).to receive(:grant_monthly_credits)
|
||||
grant = Struct.new(:id, :amount, :expires_at).new('cg_dup', 200, Time.current)
|
||||
event = build_event(type: 'billing.credit_grant.created', id: 'evt_dup', object: grant)
|
||||
context 'when handling unknown event' do
|
||||
it 'returns success' do
|
||||
event = build_event(type: 'unknown.event', object: {})
|
||||
|
||||
result = service.process(event)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(credit_service).not_to have_received(:grant_monthly_credits)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user