diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb index b671b6433..a01d065d2 100644 --- a/enterprise/app/services/enterprise/billing/v2/base_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb index 505df9936..2e0f94d19 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb new file mode 100644 index 000000000..78a5d1436 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb new file mode 100644 index 000000000..97f89b4e1 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb b/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb new file mode 100644 index 000000000..142da442f --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_service.rb index 862267328..8c2838a72 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_service.rb @@ -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] diff --git a/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb new file mode 100644 index 000000000..efbf4bb5b --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb @@ -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