feat(billing): attach attribution metadata to cloud subscriptions

This commit is contained in:
Sojan Jose
2026-04-28 19:51:47 +05:30
parent 6aeda0ddf6
commit df0daebc99
9 changed files with 159 additions and 34 deletions
@@ -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() {
@@ -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 },
}
);
});
@@ -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'),
});
@@ -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'
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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