From df0daebc999608a532f0f27b8d952fdf49bd7e0c Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 28 Apr 2026 19:40:18 +0530 Subject: [PATCH] feat(billing): attach attribution metadata to cloud subscriptions --- .../dashboard/api/enterprise/account.js | 5 +- .../api/enterprise/specs/account.spec.js | 5 +- .../dashboard/helper/billingAttribution.js | 6 ++ .../enterprise/api/v1/accounts_controller.rb | 8 +- .../enterprise/create_stripe_customer_job.rb | 4 +- .../billing/create_stripe_customer_service.rb | 34 ++++++++- .../api/v1/accounts_controller_spec.rb | 47 +++++++++--- .../create_stripe_customer_job_spec.rb | 11 ++- .../create_stripe_customer_service_spec.rb | 73 ++++++++++++++++--- 9 files changed, 159 insertions(+), 34 deletions(-) create mode 100644 app/javascript/dashboard/helper/billingAttribution.js diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 9e6d40a62..c53b58c8c 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -1,5 +1,6 @@ /* global axios */ import ApiClient from '../ApiClient'; +import { getBillingAttribution } from '../../helper/billingAttribution'; class EnterpriseAccountAPI extends ApiClient { constructor() { @@ -11,7 +12,9 @@ class EnterpriseAccountAPI extends ApiClient { } subscription() { - return axios.post(`${this.url}subscription`); + return axios.post(`${this.url}subscription`, { + billing_attribution: getBillingAttribution(), + }); } getLimits() { diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 47d2eb26d..fa81933c0 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -42,7 +42,10 @@ describe('#enterpriseAccountAPI', () => { it('#subscription', () => { accountAPI.subscription(); expect(axiosMock.post).toHaveBeenCalledWith( - '/enterprise/api/v1/subscription' + '/enterprise/api/v1/subscription', + { + billing_attribution: { visitor_id: undefined, session_id: undefined }, + } ); }); diff --git a/app/javascript/dashboard/helper/billingAttribution.js b/app/javascript/dashboard/helper/billingAttribution.js new file mode 100644 index 000000000..46559a13d --- /dev/null +++ b/app/javascript/dashboard/helper/billingAttribution.js @@ -0,0 +1,6 @@ +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 d176db597..60cd3ab14 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -7,7 +7,7 @@ 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) + Enterprise::CreateStripeCustomerJob.perform_later(@account, billing_attribution) end head :no_content end @@ -98,6 +98,12 @@ 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) + + params[:billing_attribution].permit(:visitor_id, :session_id).to_h + end + def mark_for_deletion reason = 'manual_deletion' diff --git a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb index 18223ae4b..c4086f2cb 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) - Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform + def perform(account, billing_attribution = {}) + Enterprise::Billing::CreateStripeCustomerService.new(account: account, billing_attribution: billing_attribution).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 ac1f74860..8f7351348 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!] + pattr_initialize [:account!, { billing_attribution: {} }] DEFAULT_QUANTITY = 2 @@ -7,7 +7,11 @@ 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 }]) + subscription = Stripe::Subscription.create( + customer: customer_id, + items: [{ price: price_id, quantity: default_quantity }], + metadata: stripe_metadata + ) custom_attributes = build_custom_attributes(customer_id, subscription) custom_attributes.except!('is_creating_customer') @@ -20,12 +24,36 @@ 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 }) + customer = Stripe::Customer.create({ name: account.name, email: billing_email, metadata: stripe_metadata }) customer_id = customer.id end customer_id end + def stripe_metadata + { + chatwoot_account_id: account.id + }.merge(provider_attribution_metadata.presence || existing_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 existing_provider_attribution_metadata + customer_id = account.custom_attributes['stripe_customer_id'] + return {} if customer_id.blank? + + customer = Stripe::Customer.retrieve(customer_id) + (customer.metadata || {}).to_h.symbolize_keys.slice(:datafast_visitor_id, :datafast_session_id) + rescue Stripe::StripeError => e + Rails.logger.warn("Failed to retrieve Stripe customer metadata for account #{account.id}: #{e.class} - #{e.message}") + {} + end + def default_quantity default_plan['default_quantity'] || DEFAULT_QUANTITY 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 b2a920b07..ee77a3a5a 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -26,15 +26,31 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do end context 'when it is an admin' do + before do + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') + end + it 'enqueues a job' do expect 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' } + ) + end + it 'does not enqueue a job if a job is already enqueued' do account.update!(custom_attributes: { is_creating_customer: true }) @@ -79,6 +95,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do end context 'when it is an admin and the stripe customer id is not present' do + before do + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') + end + it 'returns error' do post "/enterprise/api/v1/accounts/#{account.id}/checkout", headers: admin.create_new_auth_token, @@ -91,6 +111,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do end context 'when it is an admin and the stripe customer is present' do + before do + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') + end + it 'calls create session' do account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' }) @@ -122,8 +146,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do context 'when it is an authenticated user' do before do - InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') - InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }]) + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') + InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }]) end context 'when it is an agent' do @@ -158,8 +182,8 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do before do create(:conversation, account: account) create(:channel_api, account: account) - InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud') - InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }]) + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') + InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!(value: [{ 'name' => 'Hacker' }]) end it 'returns the limits if the plan is default' do @@ -252,10 +276,12 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do let(:stripe_invoice) { Struct.new(:id).new('inv_test123') } before do - create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [ - { 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] }, - { 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] } - ]) + InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!( + value: [ + { 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] }, + { 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] } + ] + ) end it 'returns unauthorized for unauthenticated user' do @@ -273,6 +299,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do context 'when it is an admin' do before do + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') account.update!( custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id }, limits: { 'captain_responses' => 1000 } @@ -341,7 +368,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do context 'when deployment environment is not cloud' do before do # Set deployment environment to something other than cloud - InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted') + InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'self_hosted') end it 'returns not found' do 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 b2c945800..f3c3fbf21 100644 --- a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb +++ b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb @@ -2,13 +2,14 @@ require 'rails_helper' RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do include ActiveJob::TestHelper - subject(:job) { described_class.perform_later(account) } + subject(:job) { described_class.perform_later(account, billing_attribution) } 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) + .with(account, billing_attribution) .on_queue('default') end @@ -16,12 +17,14 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do create_stripe_customer_service = double allow(Enterprise::Billing::CreateStripeCustomerService) .to receive(:new) - .with(account: account) + .with(account: account, billing_attribution: billing_attribution) .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) + expect(Enterprise::Billing::CreateStripeCustomerService) + .to have_received(:new) + .with(account: account, billing_attribution: billing_attribution) 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 0dd8189c4..7b090e021 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,6 +9,7 @@ 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' }, @@ -20,11 +21,10 @@ describe Enterprise::Billing::CreateStripeCustomerService do describe '#perform' do before do - create( - :installation_config, - { name: 'CHATWOOT_CLOUD_PLANS', value: [ + InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!( + value: [ { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } - ] } + ] ) end @@ -60,10 +60,11 @@ describe Enterprise::Billing::CreateStripeCustomerService do expect(account).not_to be_feature_enabled('help_center') end - it 'does not call stripe methods if customer id is present' do + it 'does not create a customer 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) + allow(Stripe::Customer).to receive(:retrieve).with('cus_random_number').and_return(instance_double(Stripe::Customer, metadata: {})) allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list) allow(Stripe::Subscription).to receive(:create).and_return(created_subscription) @@ -72,7 +73,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 }] }) + .with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }], metadata: stripe_metadata }) expect(account.reload.custom_attributes).to eq( { @@ -95,10 +96,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 }) + expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email, metadata: stripe_metadata }) expect(Stripe::Subscription) .to have_received(:create) - .with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] }) + .with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }], metadata: stripe_metadata }) expect(account.reload.custom_attributes).to eq( { @@ -112,15 +113,63 @@ 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 + + it 'reuses provider attribution metadata from an existing Stripe customer when attribution is not provided' do + account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' }) + expected_metadata = { + chatwoot_account_id: account.id, + datafast_visitor_id: 'visitor-123', + datafast_session_id: 'session-123' + } + stripe_customer = instance_double( + Stripe::Customer, + metadata: { + 'datafast_visitor_id' => 'visitor-123', + 'datafast_session_id' => 'session-123', + 'ignored' => 'value' + } + ) + + allow(subscriptions_list).to receive(:data).and_return([]) + allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list) + allow(Stripe::Customer).to receive(:retrieve).with('cus_random_number').and_return(stripe_customer) + allow(Stripe::Subscription).to receive(:create).and_return(created_subscription) + + create_stripe_customer_service.new(account: account).perform + + expect(Stripe::Subscription) + .to have_received(:create) + .with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }], metadata: expected_metadata }) + end end describe 'when checking for existing subscriptions' do before do - create( - :installation_config, - { name: 'CHATWOOT_CLOUD_PLANS', value: [ + InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_initialize.update!( + value: [ { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } - ] } + ] ) end