cleanup code

This commit is contained in:
Tanmay Sharma
2025-10-11 10:22:46 +04:00
parent d6f5c627eb
commit efe6e64dcd
10 changed files with 228 additions and 707 deletions
@@ -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