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 d0417fe36..9c5f58aa9 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -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 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 deleted file mode 100644 index 3f65eb9e5..000000000 --- a/enterprise/app/services/enterprise/billing/v2/stripe_credit_sync_service.rb +++ /dev/null @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb b/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb index 8c9244e37..f8febf235 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb @@ -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) diff --git a/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb index 01c8d439b..9be42675c 100644 --- a/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/usage_analytics_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb index dc4ff5e4d..c4b68d390 100644 --- a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb index 56fa87e4f..6f3299237 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb index c1a5274be..e4cecc81e 100644 --- a/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb index b40972ffc..e4ea8c37c 100644 --- a/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/usage_analytics_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb index 9ff275476..52cac742a 100644 --- a/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb index 88512606e..3e3c5e66f 100644 --- a/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb @@ -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