add credit sync job

This commit is contained in:
Tanmay Deep Sharma
2025-11-05 23:32:07 +05:30
parent b626e0cbf1
commit f225e2fcd8
3 changed files with 380 additions and 1 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# Add these to your config/sidekiq_cron.yml or config/schedule.yml
v2_credit_sync:
cron: "0 */6 * * *" # Every 6 hours
cron: "0 * * * *" # Every hour
class: "Enterprise::Billing::CreditSyncJob"
queue: low
description: "Sync V2 billing credits with Stripe"
@@ -0,0 +1,94 @@
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")
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
@@ -0,0 +1,285 @@
require 'rails_helper'
RSpec.describe Enterprise::Billing::CreditSyncJob, type: :job do
include ActiveJob::TestHelper
let(:stripe_customer_id) { 'cus_12345678' }
let(:meter_id) { 'mtr_test_123' }
let(:stripe_secret_key) { 'sk_test_123' }
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('true')
allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return(stripe_secret_key)
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: meter_id))
end
describe '#perform' do
describe 'syncing all accounts' do
let!(:account_with_stripe) do
create(:account, custom_attributes: {
'stripe_customer_id' => stripe_customer_id,
'captain_responses_usage' => 100,
'stripe_last_synced_credits' => 50
})
end
let!(:account_without_stripe) do
create(:account, custom_attributes: {
'captain_responses_usage' => 50
})
end
let(:usage_reporter) { instance_double(Enterprise::Billing::V2::UsageReporterService) }
before do
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter)
end
it 'queues the job on low priority' do
expect { described_class.perform_later }.to have_enqueued_job(described_class).on_queue('low')
end
it 'processes only accounts with Stripe customer ID' do
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' })
described_class.new.perform
expect(Enterprise::Billing::V2::UsageReporterService).to have_received(:new)
.with(account: account_with_stripe).once
expect(Enterprise::Billing::V2::UsageReporterService).not_to have_received(:new)
.with(account: account_without_stripe)
end
it 'reports the difference in credits using UsageReporterService' do
expected_credits = 50 # 100 consumed - 50 last synced
expect(usage_reporter).to receive(:report).with(expected_credits)
.and_return({ success: true, event_id: 'evt_123' })
described_class.new.perform
end
it 'updates last synced credits after successful sync' do
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' })
described_class.new.perform
account_with_stripe.reload
expect(account_with_stripe.custom_attributes['stripe_last_synced_credits']).to eq(100)
end
it 'returns sync summary' do
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' })
result = described_class.new.perform
expect(result).to eq({ synced: 1, failed: 0 })
end
context 'when credits are already in sync' do
before do
Account.delete_all
synced_account # Ensure account is created before perform
end
let(:synced_account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_synced',
'captain_responses_usage' => 100,
'stripe_last_synced_credits' => 100
})
end
it 'does not call UsageReporterService' do
described_class.new.perform
expect(Enterprise::Billing::V2::UsageReporterService).not_to have_received(:new)
end
end
context 'when usage has decreased' do
before do
Account.delete_all
end
let!(:decreased_account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_decreased',
'captain_responses_usage' => 50,
'stripe_last_synced_credits' => 100
})
end
it 'logs a warning and resets sync point' do
expect(Rails.logger).to receive(:warn).with(
/Account #{decreased_account.id} has negative difference: -50/
)
described_class.new.perform
decreased_account.reload
# Sync point should be reset to current usage
expect(decreased_account.custom_attributes['stripe_last_synced_credits']).to eq(50)
end
end
context 'when UsageReporterService returns an error' do
before do
allow(usage_reporter).to receive(:report)
.and_return({ success: false, message: 'V2 billing not enabled' })
end
it 'logs the error and continues processing' do
expect(Rails.logger).to receive(:error).at_least(:once)
described_class.new.perform
end
it 'does not update last synced credits on failure' do
original_value = account_with_stripe.custom_attributes['stripe_last_synced_credits']
described_class.new.perform
account_with_stripe.reload
expect(account_with_stripe.custom_attributes['stripe_last_synced_credits']).to eq(original_value)
end
it 'counts the failure in summary' do
result = described_class.new.perform
expect(result).to eq({ synced: 0, failed: 1 })
end
end
end
describe 'syncing single account' do
let(:account) do
create(:account, custom_attributes: {
'stripe_customer_id' => stripe_customer_id,
'captain_responses_usage' => 100,
'stripe_last_synced_credits' => 30
})
end
let(:usage_reporter) { instance_double(Enterprise::Billing::V2::UsageReporterService) }
before do
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new)
.with(account: account)
.and_return(usage_reporter)
end
it 'syncs only the specified account' do
expected_credits = 70 # 100 consumed - 30 last synced
expect(usage_reporter).to receive(:report).with(expected_credits)
.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)
end
it 'updates last synced credits for the account' do
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' })
described_class.new.perform(account)
account.reload
expect(account.custom_attributes['stripe_last_synced_credits']).to eq(100)
end
it 'returns error result when sync fails' do
allow(usage_reporter).to receive(:report)
.and_return({ success: false, message: 'Missing Stripe configuration' })
result = described_class.new.perform(account)
expect(result[:success]).to be false
expect(result[:message]).to eq('Missing Stripe configuration')
end
context 'when account has no usage' do
let(:zero_usage_account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_zero',
'captain_responses_usage' => 0
})
end
it 'does not call UsageReporterService' do
result = described_class.new.perform(zero_usage_account)
expect(Enterprise::Billing::V2::UsageReporterService).not_to have_received(:new)
expect(result[:success]).to be true
expect(result[:credits_reported]).to eq(0)
end
end
context 'when account has no stripe_last_synced_credits' do
let(:new_account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_new',
'captain_responses_usage' => 100
})
end
before do
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new)
.with(account: new_account)
.and_return(usage_reporter)
end
it 'treats last synced as 0 and reports all credits' do
expect(usage_reporter).to receive(:report).with(100)
.and_return({ success: true, event_id: 'evt_new' })
result = described_class.new.perform(new_account)
expect(result[:credits_reported]).to eq(100)
end
it 'sets stripe_last_synced_credits after first sync' do
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_new' })
described_class.new.perform(new_account)
new_account.reload
expect(new_account.custom_attributes['stripe_last_synced_credits']).to eq(100)
end
end
end
describe 'error handling' do
let(:account) do
create(:account, custom_attributes: {
'stripe_customer_id' => stripe_customer_id,
'captain_responses_usage' => 100
})
end
let(:usage_reporter) { instance_double(Enterprise::Billing::V2::UsageReporterService) }
before do
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' })
end
it 'handles and logs exceptions' do
allow(account).to receive(:with_lock).and_raise(StandardError.new('Database error'))
expect(Rails.logger).to receive(:error).with(/Error syncing account/)
expect(Rails.logger).to receive(:error).at_least(:once) # For backtrace logging
result = described_class.new.perform(account)
expect(result[:success]).to be false
expect(result[:message]).to eq('Database error')
end
end
end
end