do not disable rubocop
This commit is contained in:
@@ -68,7 +68,6 @@ class Enterprise::Billing::V2::BaseService
|
||||
def with_stripe_error_handling
|
||||
yield
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error "Stripe V2 Error: #{e.message}"
|
||||
{ success: false, message: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
|
||||
def fetch_stripe_credit_balance
|
||||
return nil unless stripe_customer_id.present? && v2_enabled?
|
||||
|
||||
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)
|
||||
sync_service.fetch_stripe_credit_balance
|
||||
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)
|
||||
sync_service.create_stripe_credit_grant(amount, type: type, metadata: metadata)
|
||||
end
|
||||
|
||||
def grant_monthly_credits(amount = 2000, metadata: {})
|
||||
@@ -23,9 +12,7 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
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
|
||||
@@ -41,7 +28,6 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
|
||||
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])
|
||||
end
|
||||
end
|
||||
@@ -49,19 +35,13 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
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&.id
|
||||
|
||||
new_balance = topup_credits + amount
|
||||
update_credits(topup: new_balance)
|
||||
|
||||
log_credit_transaction(
|
||||
type: 'topup',
|
||||
amount: amount,
|
||||
credit_type: 'topup',
|
||||
type: 'topup', amount: amount, credit_type: 'topup',
|
||||
description: 'Topup credits added',
|
||||
metadata: base_metadata(metadata).merge('stripe_grant_id' => grant_id)
|
||||
metadata: base_metadata(metadata).merge('stripe_grant_id' => stripe_grant&.id)
|
||||
)
|
||||
|
||||
{ success: true, topup_balance: new_balance, total: total_credits }
|
||||
end
|
||||
end
|
||||
@@ -71,13 +51,14 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
end
|
||||
|
||||
def credit_balance
|
||||
stripe_usage = fetch_stripe_usage_total
|
||||
stripe_usage = sync_service.fetch_stripe_usage_total
|
||||
initial_credits = initial_credits_from_local
|
||||
|
||||
if stripe_usage.is_a?(Numeric) && initial_credits
|
||||
calculate_balance_from_stripe(stripe_usage, initial_credits)
|
||||
balance = sync_service.calculate_balance_from_stripe(stripe_usage, initial_credits)
|
||||
sync_local_balance_from_stripe(balance)
|
||||
balance
|
||||
else
|
||||
local_fallback_balance
|
||||
sync_service.local_fallback_balance
|
||||
end
|
||||
end
|
||||
|
||||
@@ -90,80 +71,14 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
|
||||
private
|
||||
|
||||
def stripe_customer_id
|
||||
custom_attribute('stripe_customer_id')
|
||||
end
|
||||
|
||||
def fetch_stripe_usage_total
|
||||
return nil unless stripe_customer_id.present? && ENV['STRIPE_V2_METER_ID'].present?
|
||||
|
||||
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 extract_summaries(data)
|
||||
return [] unless data
|
||||
|
||||
if data.is_a?(Hash)
|
||||
data['data'] || data[:data] || []
|
||||
elsif data.is_a?(Array)
|
||||
data
|
||||
else
|
||||
[]
|
||||
end
|
||||
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 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_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 sync_local_balance_from_stripe(stripe_balance)
|
||||
update_credits(
|
||||
monthly: stripe_balance[:monthly],
|
||||
topup: stripe_balance[:topup]
|
||||
)
|
||||
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: {})
|
||||
@@ -171,15 +86,10 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
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)
|
||||
type: 'expire', amount: current_monthly, credit_type: 'monthly',
|
||||
description: 'Monthly credits expired', metadata: base_metadata(metadata)
|
||||
)
|
||||
|
||||
current_monthly
|
||||
end
|
||||
|
||||
@@ -187,33 +97,6 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
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',
|
||||
@@ -224,7 +107,6 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
)
|
||||
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)
|
||||
@@ -271,99 +153,10 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2
|
||||
}
|
||||
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'
|
||||
}
|
||||
def sync_local_balance_from_stripe(stripe_balance)
|
||||
update_credits(
|
||||
monthly: stripe_balance[:monthly],
|
||||
topup: stripe_balance[:topup]
|
||||
)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing::V2::BaseService
|
||||
def add_license_fee_component(plan, config)
|
||||
licensed_item = create_licensed_item(
|
||||
display_name: config[:licensed_item_display_name],
|
||||
lookup_key: config[:licensed_item_lookup_key],
|
||||
unit_label: config[:licensed_item_unit_label]
|
||||
)
|
||||
|
||||
license_fee = create_license_fee(
|
||||
display_name: config[:license_fee_display_name],
|
||||
unit_amount: config[:license_fee_amount],
|
||||
licensed_item_id: licensed_item.id
|
||||
)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'license_fee',
|
||||
data: { id: license_fee.id, version: license_fee.latest_version }
|
||||
)
|
||||
end
|
||||
|
||||
def add_service_action_component(plan, config, cpu)
|
||||
action = create_service_action(
|
||||
lookup_key: config[:service_action_lookup_key],
|
||||
credit_amount: config[:monthly_credit_amount],
|
||||
cpu_id: cpu.id
|
||||
)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'service_action',
|
||||
data: { id: action.id }
|
||||
)
|
||||
|
||||
action
|
||||
end
|
||||
|
||||
def add_rate_card_component(plan, config, meter, cpu)
|
||||
card = create_rate_card(display_name: config[:rate_card_display_name])
|
||||
|
||||
item = create_metered_item(
|
||||
display_name: config[:metered_item_display_name],
|
||||
lookup_key: config[:metered_item_lookup_key],
|
||||
meter_id: meter.id
|
||||
)
|
||||
|
||||
add_rate(card_id: card.id, item_id: item.id, cpu_id: cpu.id, value: config[:rate_value] || 1)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'rate_card',
|
||||
data: { id: card.id, version: card.latest_version }
|
||||
)
|
||||
|
||||
card
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_licensed_item(display_name:, lookup_key:, unit_label:)
|
||||
Stripe::V2::Billing::LicensedItem.create(
|
||||
{ display_name: display_name, lookup_key: lookup_key, unit_label: unit_label },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_license_fee(display_name:, unit_amount:, licensed_item_id:)
|
||||
Stripe::V2::Billing::LicenseFee.create(
|
||||
{
|
||||
display_name: display_name,
|
||||
currency: 'usd',
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
tax_behavior: 'exclusive',
|
||||
unit_amount: unit_amount,
|
||||
licensed_item: licensed_item_id
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_service_action(lookup_key:, credit_amount:, cpu_id:)
|
||||
Stripe::V2::Billing::ServiceAction.create(
|
||||
{
|
||||
lookup_key: lookup_key,
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
type: 'credit_grant',
|
||||
credit_grant: {
|
||||
name: 'Monthly Credits',
|
||||
amount: {
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: { id: cpu_id, value: credit_amount.to_s }
|
||||
},
|
||||
expiry_config: { type: 'end_of_service_period' },
|
||||
applicability_config: { scope: { price_type: 'metered' } }
|
||||
}
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_rate_card(display_name:)
|
||||
Stripe::V2::Billing::RateCard.create(
|
||||
{
|
||||
display_name: display_name,
|
||||
currency: 'usd',
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
tax_behavior: 'exclusive'
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_metered_item(display_name:, lookup_key:, meter_id:)
|
||||
Stripe::V2::Billing::MeteredItem.create(
|
||||
{ display_name: display_name, lookup_key: lookup_key, meter: meter_id },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def add_rate(card_id:, item_id:, cpu_id:, value:)
|
||||
Stripe::V2::Billing::RateCard::Rate.create(
|
||||
card_id,
|
||||
{
|
||||
metered_item: item_id,
|
||||
custom_pricing_unit_amount: { id: cpu_id, value: value.to_s }
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def add_component(plan_id:, type:, data:)
|
||||
params = case type
|
||||
when 'license_fee'
|
||||
{ type: 'license_fee', license_fee: data }
|
||||
when 'service_action'
|
||||
{ type: 'service_action', service_action: data }
|
||||
when 'rate_card'
|
||||
{ type: 'rate_card', rate_card: data }
|
||||
end
|
||||
|
||||
Stripe::V2::Billing::PricingPlan::Component.create(
|
||||
plan_id,
|
||||
params,
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::BaseService
|
||||
def create_custom_pricing_unit(display_name:, lookup_key:)
|
||||
Stripe::V2::Billing::CustomPricingUnit.create(
|
||||
{ display_name: display_name, lookup_key: lookup_key },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_meter(display_name:, event_name:)
|
||||
Stripe::Billing::Meter.create(
|
||||
{
|
||||
display_name: display_name,
|
||||
event_name: event_name,
|
||||
default_aggregation: { formula: 'sum' },
|
||||
customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id' },
|
||||
value_settings: { event_payload_key: 'value' }
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_pricing_plan(display_name:, currency: 'usd', tax_behavior: 'exclusive')
|
||||
Stripe::V2::Billing::PricingPlan.create(
|
||||
{ display_name: display_name, currency: currency, tax_behavior: tax_behavior },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_complete_pricing_plan(config)
|
||||
cpu = create_custom_pricing_unit(
|
||||
display_name: config[:cpu_display_name],
|
||||
lookup_key: config[:cpu_lookup_key]
|
||||
)
|
||||
|
||||
meter = create_meter(
|
||||
display_name: config[:meter_display_name],
|
||||
event_name: config[:meter_event_name]
|
||||
)
|
||||
|
||||
plan = create_pricing_plan(display_name: config[:plan_display_name])
|
||||
|
||||
builder = component_builder
|
||||
|
||||
builder.add_license_fee_component(plan, config) if config[:include_license_fee]
|
||||
service_action = builder.add_service_action_component(plan, config, cpu)
|
||||
rate_card = builder.add_rate_card_component(plan, config, meter, cpu)
|
||||
|
||||
build_plan_result(plan, cpu, meter, rate_card, service_action)
|
||||
rescue StandardError => e
|
||||
{ success: false, message: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def component_builder
|
||||
@component_builder ||= Enterprise::Billing::V2::PricingPlanComponentBuilder.new(account: account)
|
||||
end
|
||||
|
||||
def build_plan_result(plan, cpu, meter, rate_card, service_action)
|
||||
{
|
||||
success: true,
|
||||
pricing_plan: plan,
|
||||
custom_pricing_unit: cpu,
|
||||
meter: meter,
|
||||
rate_card: rate_card,
|
||||
service_action: service_action
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
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
|
||||
end
|
||||
@@ -1,36 +1,16 @@
|
||||
class Enterprise::Billing::V2::SubscriptionService < Enterprise::Billing::V2::BaseService
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def migrate_to_v2(plan_type: 'startup')
|
||||
return { success: false, message: 'Already on V2' } if v2_enabled?
|
||||
|
||||
credits = plan_credits(plan_type)
|
||||
|
||||
with_locked_account do
|
||||
update_custom_attributes(
|
||||
'stripe_billing_version' => 2,
|
||||
'monthly_credits' => credits,
|
||||
'topup_credits' => 0,
|
||||
'plan_name' => plan_type.capitalize,
|
||||
'subscription_status' => 'active'
|
||||
)
|
||||
|
||||
log_credit_transaction(
|
||||
type: 'grant',
|
||||
amount: credits,
|
||||
credit_type: 'monthly',
|
||||
description: "Initial V2 migration grant - #{plan_type} plan",
|
||||
metadata: { 'source' => 'migration', 'plan_type' => plan_type }
|
||||
)
|
||||
|
||||
Rails.logger.info "Migrated account #{account.id} to V2 billing with #{plan_type} plan"
|
||||
apply_migration_attributes(plan_type)
|
||||
log_migration_grant(plan_type)
|
||||
end
|
||||
|
||||
{ success: true, message: 'Successfully migrated to V2 billing' }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to migrate account #{account.id} to V2: #{e.message}"
|
||||
{ success: false, message: e.message }
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def update_plan(plan_type)
|
||||
return { success: false, message: 'Not on V2 billing' } unless v2_enabled?
|
||||
@@ -42,6 +22,28 @@ class Enterprise::Billing::V2::SubscriptionService < Enterprise::Billing::V2::Ba
|
||||
|
||||
private
|
||||
|
||||
def apply_migration_attributes(plan_type)
|
||||
credits = plan_credits(plan_type)
|
||||
update_custom_attributes(
|
||||
'stripe_billing_version' => 2,
|
||||
'monthly_credits' => credits,
|
||||
'topup_credits' => 0,
|
||||
'plan_name' => plan_type.capitalize,
|
||||
'subscription_status' => 'active'
|
||||
)
|
||||
end
|
||||
|
||||
def log_migration_grant(plan_type)
|
||||
credits = plan_credits(plan_type)
|
||||
log_credit_transaction(
|
||||
type: 'grant',
|
||||
amount: credits,
|
||||
credit_type: 'monthly',
|
||||
description: "Initial V2 migration grant - #{plan_type} plan",
|
||||
metadata: { 'source' => 'migration', 'plan_type' => plan_type }
|
||||
)
|
||||
end
|
||||
|
||||
def plan_credits(plan_type)
|
||||
config = Rails.application.config.stripe_v2
|
||||
return 100 unless config && config[:plans]
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Billing::V2::PricingPlanService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123')
|
||||
end
|
||||
|
||||
describe '#create_custom_pricing_unit' do
|
||||
it 'creates a custom pricing unit' do
|
||||
cpu_double = instance_double(Stripe::V2::Billing::CustomPricingUnit, id: 'cpu_123')
|
||||
allow(Stripe::V2::Billing::CustomPricingUnit).to receive(:create).and_return(cpu_double)
|
||||
|
||||
result = service.create_custom_pricing_unit(display_name: 'Credits', lookup_key: 'credits_001')
|
||||
|
||||
expect(result.id).to eq('cpu_123')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_meter' do
|
||||
it 'creates a billing meter' do
|
||||
meter_double = instance_double(Stripe::Billing::Meter, id: 'meter_123')
|
||||
allow(Stripe::Billing::Meter).to receive(:create).and_return(meter_double)
|
||||
|
||||
result = service.create_meter(display_name: 'Prompts', event_name: 'prompts_001')
|
||||
|
||||
expect(result.id).to eq('meter_123')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_pricing_plan' do
|
||||
it 'creates a pricing plan' do
|
||||
plan_double = instance_double(Stripe::V2::Billing::PricingPlan, id: 'plan_123')
|
||||
allow(Stripe::V2::Billing::PricingPlan).to receive(:create).and_return(plan_double)
|
||||
|
||||
result = service.create_pricing_plan(display_name: 'Business Plan')
|
||||
|
||||
expect(result.id).to eq('plan_123')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_service_action' do
|
||||
it 'creates a service action for monthly credit grants' do
|
||||
action_double = instance_double(Stripe::V2::Billing::ServiceAction, id: 'sa_123')
|
||||
allow(Stripe::V2::Billing::ServiceAction).to receive(:create).and_return(action_double)
|
||||
|
||||
result = service.create_service_action(
|
||||
lookup_key: 'monthly_credits_001',
|
||||
cpu_id: 'cpu_123',
|
||||
credit_amount: 2000
|
||||
)
|
||||
|
||||
expect(result.id).to eq('sa_123')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_complete_pricing_plan' do
|
||||
let(:config) do
|
||||
{
|
||||
cpu_display_name: 'Captain Credits',
|
||||
cpu_lookup_key: 'captain_credits_001',
|
||||
meter_display_name: 'Captain Prompts',
|
||||
meter_event_name: 'captain_prompts_001',
|
||||
plan_display_name: 'Business Plan - 2000 Credits',
|
||||
include_license_fee: true,
|
||||
licensed_item_display_name: 'Business Seat',
|
||||
licensed_item_lookup_key: 'business_seat_001',
|
||||
licensed_item_unit_label: 'per agent',
|
||||
license_fee_display_name: 'Business Monthly Fee',
|
||||
license_fee_amount: '3900',
|
||||
service_action_lookup_key: 'monthly_credits_001',
|
||||
monthly_credit_amount: 2000,
|
||||
rate_card_display_name: 'Usage Rates',
|
||||
metered_item_display_name: 'Captain Prompt',
|
||||
metered_item_lookup_key: 'captain_prompt_001',
|
||||
rate_value: 1
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a complete pricing plan with all components' do
|
||||
cpu = instance_double(Stripe::V2::Billing::CustomPricingUnit, id: 'cpu_123')
|
||||
meter = instance_double(Stripe::Billing::Meter, id: 'meter_123')
|
||||
plan = instance_double(Stripe::V2::Billing::PricingPlan, id: 'plan_123')
|
||||
licensed_item = instance_double(Stripe::V2::Billing::LicensedItem, id: 'li_123')
|
||||
license_fee = instance_double(Stripe::V2::Billing::LicenseFee, id: 'lf_123', latest_version: 'v1')
|
||||
service_action = instance_double(Stripe::V2::Billing::ServiceAction, id: 'sa_123')
|
||||
rate_card = instance_double(Stripe::V2::Billing::RateCard, id: 'rc_123', latest_version: 'v1')
|
||||
metered_item = instance_double(Stripe::V2::Billing::MeteredItem, id: 'mi_123')
|
||||
|
||||
allow(service).to receive(:create_custom_pricing_unit).and_return(cpu)
|
||||
allow(service).to receive(:create_meter).and_return(meter)
|
||||
allow(service).to receive(:create_pricing_plan).and_return(plan)
|
||||
allow(service).to receive(:create_licensed_item).and_return(licensed_item)
|
||||
allow(service).to receive(:create_license_fee).and_return(license_fee)
|
||||
allow(service).to receive(:create_service_action).and_return(service_action)
|
||||
allow(service).to receive(:create_rate_card).and_return(rate_card)
|
||||
allow(service).to receive(:create_metered_item).and_return(metered_item)
|
||||
allow(service).to receive(:add_rate_to_rate_card).and_return(true)
|
||||
allow(service).to receive(:add_component_to_plan).and_return(true)
|
||||
|
||||
result = service.create_complete_pricing_plan(config)
|
||||
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:pricing_plan]).to eq(plan)
|
||||
expect(result[:custom_pricing_unit]).to eq(cpu)
|
||||
expect(result[:meter]).to eq(meter)
|
||||
expect(result[:service_action]).to eq(service_action)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user