From 60aac8d73110fe1a6829a0fbd36af1c38c2c600d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 28 Apr 2026 23:11:04 +0530 Subject: [PATCH] fix(billing): track cloud payment attribution --- .../dashboard/api/enterprise/account.js | 5 +- .../api/enterprise/specs/account.spec.js | 5 +- .../dashboard/helper/billingAttribution.js | 6 -- .../enterprise/api/v1/accounts_controller.rb | 17 ++- .../enterprise/create_stripe_customer_job.rb | 4 +- .../billing/create_stripe_customer_service.rb | 23 +--- .../billing/handle_stripe_event_service.rb | 16 +++ .../track_payment_attribution_service.rb | 100 ++++++++++++++++++ .../api/v1/accounts_controller_spec.rb | 23 ++-- .../create_stripe_customer_job_spec.rb | 11 +- .../create_stripe_customer_service_spec.rb | 44 +++----- .../handle_stripe_event_service_spec.rb | 53 ++++++---- .../track_payment_attribution_service_spec.rb | 73 +++++++++++++ 13 files changed, 272 insertions(+), 108 deletions(-) delete mode 100644 app/javascript/dashboard/helper/billingAttribution.js create mode 100644 enterprise/app/services/enterprise/billing/track_payment_attribution_service.rb create mode 100644 spec/enterprise/services/enterprise/billing/track_payment_attribution_service_spec.rb diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index c53b58c8c..9e6d40a62 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -1,6 +1,5 @@ /* global axios */ import ApiClient from '../ApiClient'; -import { getBillingAttribution } from '../../helper/billingAttribution'; class EnterpriseAccountAPI extends ApiClient { constructor() { @@ -12,9 +11,7 @@ class EnterpriseAccountAPI extends ApiClient { } subscription() { - return axios.post(`${this.url}subscription`, { - billing_attribution: getBillingAttribution(), - }); + return axios.post(`${this.url}subscription`); } getLimits() { diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index fa81933c0..47d2eb26d 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -42,10 +42,7 @@ describe('#enterpriseAccountAPI', () => { it('#subscription', () => { accountAPI.subscription(); expect(axiosMock.post).toHaveBeenCalledWith( - '/enterprise/api/v1/subscription', - { - billing_attribution: { visitor_id: undefined, session_id: undefined }, - } + '/enterprise/api/v1/subscription' ); }); diff --git a/app/javascript/dashboard/helper/billingAttribution.js b/app/javascript/dashboard/helper/billingAttribution.js deleted file mode 100644 index 46559a13d..000000000 --- a/app/javascript/dashboard/helper/billingAttribution.js +++ /dev/null @@ -1,6 +0,0 @@ -import Cookies from 'js-cookie'; - -export const getBillingAttribution = () => ({ - visitor_id: Cookies.get('datafast_visitor_id'), - session_id: Cookies.get('datafast_session_id'), -}); diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 783906d38..b13acd753 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -6,8 +6,8 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController def subscription if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? - @account.update(custom_attributes: { is_creating_customer: true }) - Enterprise::CreateStripeCustomerJob.perform_later(@account, billing_attribution) + @account.update(custom_attributes: subscription_custom_attributes) + Enterprise::CreateStripeCustomerJob.perform_later(@account) end head :no_content end @@ -98,10 +98,17 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController @account.custom_attributes['stripe_customer_id'] end - def billing_attribution - return {} unless params[:billing_attribution].respond_to?(:permit) + def subscription_custom_attributes + attributes = @account.custom_attributes.merge('is_creating_customer' => true) + attributes['billing_attribution'] = billing_attribution if billing_attribution.present? + attributes + end - params[:billing_attribution].permit(:visitor_id, :session_id).to_h + def billing_attribution + { + 'datafast_visitor_id' => cookies[:datafast_visitor_id], + 'datafast_session_id' => cookies[:datafast_session_id] + }.compact end def mark_for_deletion diff --git a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb index c4086f2cb..18223ae4b 100644 --- a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb +++ b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb @@ -1,8 +1,8 @@ class Enterprise::CreateStripeCustomerJob < ApplicationJob queue_as :default - def perform(account, billing_attribution = {}) - Enterprise::Billing::CreateStripeCustomerService.new(account: account, billing_attribution: billing_attribution).perform + def perform(account) + Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform ensure # Always clear the is_creating_customer flag, even if the job fails # This prevents users from getting stuck on the billing page diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb index 0c061f429..ac1f74860 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -1,5 +1,5 @@ class Enterprise::Billing::CreateStripeCustomerService - pattr_initialize [:account!, { billing_attribution: {} }] + pattr_initialize [:account!] DEFAULT_QUANTITY = 2 @@ -7,11 +7,7 @@ class Enterprise::Billing::CreateStripeCustomerService return if existing_subscription? customer_id = prepare_customer_id - subscription = Stripe::Subscription.create( - customer: customer_id, - items: [{ price: price_id, quantity: default_quantity }], - metadata: stripe_metadata - ) + subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }]) custom_attributes = build_custom_attributes(customer_id, subscription) custom_attributes.except!('is_creating_customer') @@ -24,25 +20,12 @@ class Enterprise::Billing::CreateStripeCustomerService def prepare_customer_id customer_id = account.custom_attributes['stripe_customer_id'] if customer_id.blank? - customer = Stripe::Customer.create({ name: account.name, email: billing_email, metadata: stripe_metadata }) + customer = Stripe::Customer.create({ name: account.name, email: billing_email }) customer_id = customer.id end customer_id end - def stripe_metadata - { - chatwoot_account_id: account.id - }.merge(provider_attribution_metadata) - end - - def provider_attribution_metadata - { - datafast_visitor_id: billing_attribution[:visitor_id].presence || billing_attribution['visitor_id'].presence, - datafast_session_id: billing_attribution[:session_id].presence || billing_attribution['session_id'].presence - }.compact - end - def default_quantity default_plan['default_quantity'] || DEFAULT_QUANTITY end diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index f69edeb45..b19ab5e0e 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -14,6 +14,8 @@ class Enterprise::Billing::HandleStripeEventService process_subscription_updated when 'customer.subscription.deleted' process_subscription_deleted + when 'invoice.paid', 'invoice.payment_succeeded' + process_invoice_paid else Rails.logger.debug { "Unhandled event type: #{event.type}" } end @@ -74,6 +76,12 @@ class Enterprise::Billing::HandleStripeEventService Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform end + def process_invoice_paid + return if invoice_account.blank? + + Enterprise::Billing::TrackPaymentAttributionService.new(account: invoice_account, invoice: invoice).perform + end + def handle_subscription_credits(plan, previous_usage) current_limits = account.limits || {} @@ -109,6 +117,10 @@ class Enterprise::Billing::HandleStripeEventService @subscription ||= @event.data.object end + def invoice + @invoice ||= @event.data.object + end + def previous_attributes @previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json) end @@ -132,6 +144,10 @@ class Enterprise::Billing::HandleStripeEventService @account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first end + def invoice_account + @invoice_account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", invoice.customer).first + end + def find_plan(plan_id) cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] cloud_plans.find { |config| config['product_id'].include?(plan_id) } diff --git a/enterprise/app/services/enterprise/billing/track_payment_attribution_service.rb b/enterprise/app/services/enterprise/billing/track_payment_attribution_service.rb new file mode 100644 index 000000000..8243e45aa --- /dev/null +++ b/enterprise/app/services/enterprise/billing/track_payment_attribution_service.rb @@ -0,0 +1,100 @@ +class Enterprise::Billing::TrackPaymentAttributionService + pattr_initialize [:account!, :invoice!] + + API_ENDPOINT = 'https://datafa.st/api/v1/payments'.freeze + API_KEY_CONFIG = 'DATAFAST_API_KEY'.freeze + ZERO_DECIMAL_CURRENCIES = %w[BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF].freeze + + def perform + return unless trackable? + + response = HTTParty.post( + API_ENDPOINT, + headers: { + 'Authorization' => "Bearer #{api_key}", + 'Content-Type' => 'application/json' + }, + body: payload.to_json, + timeout: 5 + ) + + log_failure("#{response.code} #{response.body}") unless response.success? + rescue StandardError => e + log_failure("#{e.class} - #{e.message}") + end + + private + + def trackable? + ChatwootApp.chatwoot_cloud? && [api_key, datafast_visitor_id, amount_paid, currency, transaction_id].all?(&:present?) + end + + def payload + { + amount: amount, + currency: currency.upcase, + transaction_id: transaction_id, + datafast_visitor_id: datafast_visitor_id, + email: customer_email, + name: customer_name, + customer_id: customer_id, + renewal: renewal? + }.compact + end + + def amount + return amount_paid if ZERO_DECIMAL_CURRENCIES.include?(currency.upcase) + + amount_paid.to_f / 100 + end + + def amount_paid + invoice_value('amount_paid') + end + + def currency + invoice_value('currency') + end + + def transaction_id + invoice_value('id') + end + + def customer_id + invoice_value('customer') || account.custom_attributes['stripe_customer_id'] + end + + def customer_email + invoice_value('customer_email') || account.administrators.first&.email + end + + def customer_name + invoice_value('customer_name') || account.name + end + + def renewal? + invoice_value('billing_reason') == 'subscription_cycle' + end + + def datafast_visitor_id + attribution['datafast_visitor_id'] + end + + def attribution + account.custom_attributes['billing_attribution'] || {} + end + + def api_key + GlobalConfigService.load(API_KEY_CONFIG, nil) + end + + def log_failure(message) + Rails.logger.warn("Payment attribution failed for invoice #{transaction_id}: #{message}") + end + + def invoice_value(key) + invoice[key] + rescue NoMethodError + nil + end +end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb index 25031fa5e..7f589349c 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -35,19 +35,22 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do post "/enterprise/api/v1/accounts/#{account.id}/subscription", headers: admin.create_new_auth_token, as: :json - end.to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account, {}) + end.to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account) expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access) end - it 'passes billing attribution to the stripe customer job' do - expect do - post "/enterprise/api/v1/accounts/#{account.id}/subscription", - headers: admin.create_new_auth_token, - params: { billing_attribution: { visitor_id: 'visitor-123', session_id: 'session-123' } }, - as: :json - end.to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with( - account, - { 'visitor_id' => 'visitor-123', 'session_id' => 'session-123' } + it 'stores billing attribution from request cookies' do + post "/enterprise/api/v1/accounts/#{account.id}/subscription", + headers: admin.create_new_auth_token.merge( + 'Cookie' => 'datafast_visitor_id=visitor-123; datafast_session_id=session-123' + ), + as: :json + + expect(account.reload.custom_attributes['billing_attribution']).to eq( + { + 'datafast_visitor_id' => 'visitor-123', + 'datafast_session_id' => 'session-123' + } ) end diff --git a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb index f3c3fbf21..b2c945800 100644 --- a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb +++ b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb @@ -2,14 +2,13 @@ require 'rails_helper' RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do include ActiveJob::TestHelper - subject(:job) { described_class.perform_later(account, billing_attribution) } + subject(:job) { described_class.perform_later(account) } let(:account) { create(:account) } - let(:billing_attribution) { { 'visitor_id' => 'visitor-123', 'session_id' => 'session-123' } } it 'queues the job' do expect { job }.to have_enqueued_job(described_class) - .with(account, billing_attribution) + .with(account) .on_queue('default') end @@ -17,14 +16,12 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do create_stripe_customer_service = double allow(Enterprise::Billing::CreateStripeCustomerService) .to receive(:new) - .with(account: account, billing_attribution: billing_attribution) + .with(account: account) .and_return(create_stripe_customer_service) allow(create_stripe_customer_service).to receive(:perform) perform_enqueued_jobs { job } - expect(Enterprise::Billing::CreateStripeCustomerService) - .to have_received(:new) - .with(account: account, billing_attribution: billing_attribution) + expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account) end end diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb index ad6401ed0..0dd8189c4 100644 --- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb @@ -9,7 +9,6 @@ describe Enterprise::Billing::CreateStripeCustomerService do let(:subscriptions_list) { double } let(:current_period_end) { 1_686_567_520 } let(:subscription_ends_on) { Time.zone.at(current_period_end).as_json } - let(:stripe_metadata) { { chatwoot_account_id: account.id } } let(:created_subscription) do { plan: { id: 'price_random_number', product: 'prod_random_number' }, @@ -21,10 +20,11 @@ describe Enterprise::Billing::CreateStripeCustomerService do describe '#perform' do before do - InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!( - value: [ + create( + :installation_config, + { name: 'CHATWOOT_CLOUD_PLANS', value: [ { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } - ] + ] } ) end @@ -60,7 +60,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do expect(account).not_to be_feature_enabled('help_center') end - it 'does not create a customer if customer id is present' do + it 'does not call stripe methods if customer id is present' do account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' }) allow(subscriptions_list).to receive(:data).and_return([]) allow(Stripe::Customer).to receive(:create) @@ -72,7 +72,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do expect(Stripe::Customer).not_to have_received(:create) expect(Stripe::Subscription) .to have_received(:create) - .with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }], metadata: stripe_metadata }) + .with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] }) expect(account.reload.custom_attributes).to eq( { @@ -95,10 +95,10 @@ describe Enterprise::Billing::CreateStripeCustomerService do create_stripe_customer_service.new(account: account).perform - expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email, metadata: stripe_metadata }) + expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email }) expect(Stripe::Subscription) .to have_received(:create) - .with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }], metadata: stripe_metadata }) + .with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] }) expect(account.reload.custom_attributes).to eq( { @@ -112,35 +112,15 @@ describe Enterprise::Billing::CreateStripeCustomerService do }.with_indifferent_access ) end - - it 'adds billing attribution to Stripe customer and subscription metadata' do - customer = double - billing_attribution = { 'visitor_id' => 'visitor-123', 'session_id' => 'session-123' } - expected_metadata = { - chatwoot_account_id: account.id, - datafast_visitor_id: 'visitor-123', - datafast_session_id: 'session-123' - } - - allow(Stripe::Customer).to receive(:create).and_return(customer) - allow(customer).to receive(:id).and_return('cus_random_number') - allow(Stripe::Subscription).to receive(:create).and_return(created_subscription) - - create_stripe_customer_service.new(account: account, billing_attribution: billing_attribution).perform - - expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email, metadata: expected_metadata }) - expect(Stripe::Subscription) - .to have_received(:create) - .with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }], metadata: expected_metadata }) - end end describe 'when checking for existing subscriptions' do before do - InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!( - value: [ + create( + :installation_config, + { name: 'CHATWOOT_CLOUD_PLANS', value: [ { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } - ] + ] } ) end diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index f9b550ef8..3e2018981 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -10,25 +10,23 @@ describe Enterprise::Billing::HandleStripeEventService do before do # Create cloud plans configuration - create(:installation_config, { - name: 'CHATWOOT_CLOUD_PLANS', - value: [ - { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] }, - { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }, - { 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] }, - { 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] } - ] - }) + InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!( + value: [ + { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] }, + { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }, + { 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] }, + { 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] } + ] + ) - create(:installation_config, { - name: 'CAPTAIN_CLOUD_PLAN_LIMITS', - value: { - 'hacker' => { 'responses' => 0 }, - 'startups' => { 'responses' => 300 }, - 'business' => { 'responses' => 500 }, - 'enterprise' => { 'responses' => 800 } - } - }) + InstallationConfig.where(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').first_or_initialize.update!( + value: { + 'hacker' => { 'responses' => 0 }, + 'startups' => { 'responses' => 300 }, + 'business' => { 'responses' => 500 }, + 'enterprise' => { 'responses' => 800 } + } + ) # Setup common subscription mocks allow(event).to receive(:data).and_return(data) allow(data).to receive(:object).and_return(subscription) @@ -133,6 +131,25 @@ describe Enterprise::Billing::HandleStripeEventService do end end + describe 'invoice payment handling' do + let(:invoice) { instance_double(Stripe::Invoice, customer: 'cus_123') } + + it 'tracks payment attribution on paid invoices' do + allow(event).to receive(:type).and_return('invoice.paid') + allow(data).to receive(:object).and_return(invoice) + + attribution_service = instance_double(Enterprise::Billing::TrackPaymentAttributionService, perform: true) + allow(Enterprise::Billing::TrackPaymentAttributionService) + .to receive(:new) + .with(account: account, invoice: invoice) + .and_return(attribution_service) + + stripe_event_service.new.perform(event: event) + + expect(attribution_service).to have_received(:perform) + end + end + describe 'plan-specific feature management' do context 'with default plan (Hacker)' do it 'disables all premium features' do diff --git a/spec/enterprise/services/enterprise/billing/track_payment_attribution_service_spec.rb b/spec/enterprise/services/enterprise/billing/track_payment_attribution_service_spec.rb new file mode 100644 index 000000000..cfcea90f5 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/track_payment_attribution_service_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe Enterprise::Billing::TrackPaymentAttributionService do + subject(:service) { described_class.new(account: account, invoice: invoice) } + + let(:account) do + create( + :account, + custom_attributes: { + 'stripe_customer_id' => 'cus_123', + 'billing_attribution' => { + 'datafast_visitor_id' => 'visitor-123' + } + } + ) + end + let!(:admin) { create(:user, account: account, role: :administrator, email: 'admin@example.com') } + let(:invoice) do + { + 'id' => 'in_123', + 'amount_paid' => 2900, + 'currency' => 'usd', + 'customer' => 'cus_123', + 'customer_name' => 'Acme Finance', + 'billing_reason' => 'subscription_create' + } + end + + before do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + allow(HTTParty).to receive(:post).and_return(instance_double(HTTParty::Response, success?: true)) + allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return('test-key') + end + + it 'sends payment attribution to the payment API' do + service.perform + + expect(HTTParty).to have_received(:post).with( + 'https://datafa.st/api/v1/payments', + headers: { + 'Authorization' => 'Bearer test-key', + 'Content-Type' => 'application/json' + }, + body: { + amount: 29.0, + currency: 'USD', + transaction_id: 'in_123', + datafast_visitor_id: 'visitor-123', + email: admin.email, + name: 'Acme Finance', + customer_id: 'cus_123', + renewal: false + }.to_json, + timeout: 5 + ) + end + + it 'skips the API call when attribution is unavailable' do + account.update!(custom_attributes: { 'stripe_customer_id' => 'cus_123' }) + + service.perform + + expect(HTTParty).not_to have_received(:post) + end + + it 'skips the API call when the installation config is unavailable' do + allow(GlobalConfigService).to receive(:load).with('DATAFAST_API_KEY', nil).and_return(nil) + + service.perform + + expect(HTTParty).not_to have_received(:post) + end +end