diff --git a/config/schedule_v2_billing.yml b/config/schedule_v2_billing.yml new file mode 100644 index 000000000..0a7ae44dd --- /dev/null +++ b/config/schedule_v2_billing.yml @@ -0,0 +1,8 @@ +# Stripe V2 Billing Scheduled Jobs +# Add these to your config/sidekiq_cron.yml or config/schedule.yml + +v2_credit_sync: + cron: "0 * * * *" # Every hour + class: "Enterprise::Billing::CreditSyncJob" + queue: low + description: "Sync V2 billing credits with Stripe" diff --git a/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb b/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb new file mode 100644 index 000000000..7cc073b8e --- /dev/null +++ b/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb @@ -0,0 +1,96 @@ +class Enterprise::Billing::CreditSyncJob < ApplicationJob + queue_as :low + + def perform(account = nil) + if account + sync_single_account(account) + else + sync_all_accounts + end + end + + private + + def sync_all_accounts + Rails.logger.info '[CreditSyncJob] Starting credit sync for all accounts' + + accounts_with_stripe = Account.where( + "custom_attributes->>'stripe_customer_id' IS NOT NULL AND (custom_attributes->>'stripe_billing_version')::integer = 2" + ) + synced_count = 0 + failed_count = 0 + + accounts_with_stripe.find_each do |account| + result = sync_account_credits(account) + if result[:success] + synced_count += 1 if result[:credits_reported].to_i.positive? + else + failed_count += 1 + Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}" + end + end + + Rails.logger.info "[CreditSyncJob] Completed. Synced: #{synced_count}, Failed: #{failed_count}" + { synced: synced_count, failed: failed_count } + end + + def sync_single_account(account) + Rails.logger.info "[CreditSyncJob] Syncing credits for account #{account.id}" + result = sync_account_credits(account) + + if result[:success] + Rails.logger.info "[CreditSyncJob] Successfully synced account #{account.id}" + else + Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}" + end + + result + end + + def sync_account_credits(account) + consumed_credits = account.custom_attributes&.[]('captain_responses_usage').to_i + last_synced_credits = account.custom_attributes&.[]('stripe_last_synced_credits').to_i + credits_to_report = consumed_credits - last_synced_credits + + if credits_to_report.positive? + handle_positive_credits(account, credits_to_report, consumed_credits) + elsif credits_to_report.negative? + handle_negative_credits(account, credits_to_report, consumed_credits) + else + { success: true, message: 'Already in sync', credits_reported: 0 } + end + rescue StandardError => e + handle_sync_error(account, e) + end + + def handle_positive_credits(account, credits_to_report, consumed_credits) + reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account) + result = reporter.report(credits_to_report) + + return result unless result[:success] + + update_last_synced_credits(account, consumed_credits) + Rails.logger.info "[CreditSyncJob] Account #{account.id}: reported #{credits_to_report} credits (total: #{consumed_credits})" + result.merge(credits_reported: credits_to_report) + end + + def handle_negative_credits(account, credits_to_report, consumed_credits) + Rails.logger.warn "[CreditSyncJob] Account #{account.id} has negative difference: #{credits_to_report}" + update_last_synced_credits(account, consumed_credits) + { success: true, message: 'Reset sync point due to negative difference', credits_reported: 0 } + end + + def handle_sync_error(account, error) + Rails.logger.error "[CreditSyncJob] Error syncing account #{account.id}: #{error.message}" + Rails.logger.error error.backtrace.join("\n") + { success: false, message: error.message } + end + + def update_last_synced_credits(account, credits) + account.with_lock do + current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {} + current_attributes['stripe_last_synced_credits'] = credits + account.update!(custom_attributes: current_attributes) + end + 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 new file mode 100644 index 000000000..989acc9dd --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb @@ -0,0 +1,35 @@ +class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService + def report(credits_used, _feature = nil) + return { success: false, message: 'Missing Stripe configuration' } unless valid_configuration? + + event = Stripe::Billing::MeterEvent.create( + meter_event_params(credits_used), + stripe_api_options + ) + + { success: true, event_id: event.identifier } + rescue Stripe::StripeError => e + { success: false, message: e.message } + end + + private + + def valid_configuration? + stripe_customer_id.present? + end + + def meter_event_params(credits_used) + { + event_name: 'chatwoot.usage', + payload: { + value: credits_used.to_s, + stripe_customer_id: stripe_customer_id + }, + identifier: "#{account.id}_#{Time.current.to_i}_#{SecureRandom.hex(4)}" + } + end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end +end diff --git a/spec/enterprise/jobs/enterprise/billing/credit_sync_job_spec.rb b/spec/enterprise/jobs/enterprise/billing/credit_sync_job_spec.rb new file mode 100644 index 000000000..d8c24fc97 --- /dev/null +++ b/spec/enterprise/jobs/enterprise/billing/credit_sync_job_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe Enterprise::Billing::CreditSyncJob, type: :job do + include ActiveJob::TestHelper + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123') + allow(InstallationConfig).to receive(:find_by).and_call_original + allow(InstallationConfig).to receive(:find_by).with(name: 'STRIPE_METER_ID') + .and_return(instance_double(InstallationConfig, value: 'mtr_test_123')) + end + + describe '#perform' do + context 'with no arguments' do + let!(:account) do + create(:account, custom_attributes: { + 'stripe_customer_id' => 'cus_123', + 'stripe_billing_version' => 2, + 'captain_responses_usage' => 100, + 'stripe_last_synced_credits' => 50 + }) + end + + it 'syncs all accounts with Stripe customer ID' do + usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService) + allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter) + allow(usage_reporter).to receive(:report).with(50).and_return({ success: true, event_id: 'evt_123' }) + + result = described_class.new.perform + + expect(result).to eq({ synced: 1, failed: 0 }) + expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100) + end + end + + context 'with account argument' do + let(:account) do + create(:account, custom_attributes: { + 'stripe_customer_id' => 'cus_123', + 'captain_responses_usage' => 100, + 'stripe_last_synced_credits' => 30 + }) + end + + it 'syncs single account' do + usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService) + allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter) + allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' }) + + result = described_class.new.perform(account) + + expect(result[:success]).to be true + expect(result[:credits_reported]).to eq(70) + expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100) + end + end + end +end