fix(billing): track cloud payment attribution

This commit is contained in:
Sojan Jose
2026-04-28 23:11:04 +05:30
parent bfdd5d2642
commit 60aac8d731
13 changed files with 272 additions and 108 deletions
@@ -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() {
@@ -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'
);
});
@@ -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'),
});
@@ -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
@@ -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
@@ -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
@@ -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) }
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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