chore: Track cloud plan activation conversions (#14834)

## Summary
- track cloud plan activation conversions when an attributed account
moves from the configured default cloud plan to a paid plan
- use the Stripe webhook event time as the activation timestamp so the
30-day signup attribution window reflects the actual upgrade event
- send the Stripe subscription amount and currency for the conversion
value
- mark the account attribution after enqueueing so later plan updates do
not send duplicate activation conversions

## Notes
- Marketing tracker: https://linear.app/chatwoot/issue/MAR-113
- Cloud implementation: https://linear.app/chatwoot/issue/LEA-34
- Stripe billing stays responsible for subscription state and value
calculation.
- Cloud plan activation conversion tracking is handled by a small
dedicated service that owns the activation rule, duplicate marker, and
conversion enqueue.
- Website attribution cookie capture remains separate in the marketing
attribution service.
- There is no frontend change and no new user-facing configuration.
- Conversion upload still no-ops outside Chatwoot Cloud and when
attribution has no supported click identifier.
This commit is contained in:
Sojan Jose
2026-06-25 18:41:43 -07:00
committed by GitHub
parent cf134deb37
commit d0b1c055e8
4 changed files with 189 additions and 1 deletions
@@ -30,7 +30,11 @@ class Enterprise::Billing::HandleStripeEventService
previous_usage = capture_previous_usage
update_account_attributes(subscription, plan)
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
sync_subscription_credits(plan, previous_usage)
track_marketing_plan_activation(previous_plan_name, plan['name']) if plan_changed?
end
def sync_subscription_credits(plan, previous_usage)
if billing_period_renewed?
ActiveRecord::Base.transaction do
handle_subscription_credits(plan, previous_usage)
@@ -66,6 +70,23 @@ class Enterprise::Billing::HandleStripeEventService
)
end
def track_marketing_plan_activation(previous_plan_name, current_plan_name)
subscription_plan = subscription['plan']
Internal::Accounts::CloudPlanActivationConversionService.new(
account: account,
previous_plan_name: previous_plan_name,
current_plan_name: current_plan_name,
activated_at: Time.zone.at(@event.created),
conversion_value: subscription_conversion_value(subscription_plan),
currency_code: subscription_plan['currency'].upcase
).perform
end
def subscription_conversion_value(subscription_plan)
((subscription_plan['amount'] || subscription_plan['amount_decimal']).to_d * subscription['quantity'].to_i / 100).to_f
end
def process_subscription_deleted
# skipping self hosted plan events
return if account.blank?
@@ -141,7 +162,17 @@ class Enterprise::Billing::HandleStripeEventService
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) }
end
def previous_plan_name
stripe_plan = previous_attributes['plan']
return if stripe_plan.blank?
find_plan(stripe_plan['product'])&.dig('name')
end
def cloud_plans
@cloud_plans ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
class Internal::Accounts::CloudPlanActivationConversionService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'
PLAN_ACTIVATION_TRACKED_AT = 'cloud_plan_activation_tracked_at'
pattr_initialize [:account!, :previous_plan_name!, :current_plan_name!, :activated_at!, :conversion_value!, :currency_code!]
def perform
return unless ChatwootApp.chatwoot_cloud?
return unless previous_plan_name == default_plan_name && current_plan_name != default_plan_name
return if marketing_attribution.blank? || marketing_attribution[PLAN_ACTIVATION_TRACKED_AT].present?
return if activated_at > account.created_at + 30.days
enqueue_conversion
mark_tracked
end
private
def default_plan_name
@default_plan_name ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG).value.first['name']
end
def marketing_attribution
@marketing_attribution ||= internal_attributes_service.get('marketing_attribution')
end
def enqueue_conversion
Internal::Accounts::MarketingConversionTrackingJob.perform_later(
account.id,
'cloud_plan_activation',
activated_at,
conversion_value,
currency_code
)
end
def mark_tracked
internal_attributes_service.set(
'marketing_attribution',
marketing_attribution.merge(PLAN_ACTIVATION_TRACKED_AT => Time.current.iso8601)
)
end
def internal_attributes_service
@internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
end
end
@@ -37,6 +37,7 @@ describe Enterprise::Billing::HandleStripeEventService do
allow(subscription).to receive(:[]).with('status').and_return('active')
allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520)
allow(subscription).to receive(:customer).and_return('cus_123')
allow(event).to receive(:created).and_return(account.created_at.to_i + 1.day.to_i)
allow(event).to receive(:type).and_return('customer.subscription.updated')
end
@@ -97,6 +98,37 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account.reload.custom_attributes['subscribed_quantity']).to eq(6)
end
it 'tracks marketing attribution for plan activation' do
account.update!(
custom_attributes: account.custom_attributes.merge('plan_name' => 'Startups')
)
allow(subscription).to receive(:[]).with('plan')
.and_return({
'id' => 'price_startups',
'product' => 'plan_id_startups',
'name' => 'Startups',
'amount' => 19_900,
'currency' => 'usd'
})
allow(subscription).to receive(:[]).with('quantity').and_return(2)
allow(data).to receive(:previous_attributes).and_return({ 'plan' => { 'product' => 'plan_id_hacker' } })
conversion_service = instance_double(Internal::Accounts::CloudPlanActivationConversionService)
allow(Internal::Accounts::CloudPlanActivationConversionService).to receive(:new).and_return(conversion_service)
allow(conversion_service).to receive(:perform)
stripe_event_service.new.perform(event: event)
expect(Internal::Accounts::CloudPlanActivationConversionService).to have_received(:new).with(
account: account,
previous_plan_name: 'Hacker',
current_plan_name: 'Startups',
activated_at: Time.zone.at(account.created_at.to_i + 1.day.to_i),
conversion_value: 398.0,
currency_code: 'USD'
)
expect(conversion_service).to have_received(:perform)
end
it 'persists quantity even when increment_response_usage runs concurrently' do
allow(subscription).to receive(:[]).with('quantity').and_return(6)
account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100))
@@ -0,0 +1,75 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Internal::Accounts::CloudPlanActivationConversionService do
let(:account) { create(:account) }
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'Hacker' },
{ 'name' => 'Startups' }
])
account.update!(
internal_attributes: {
'marketing_attribution' => { 'last_touch' => { 'gclid' => 'test-click-id' } }
}
)
end
it 'enqueues conversion tracking and marks the activation as tracked' do
described_class.new(
account: account,
previous_plan_name: 'Hacker',
current_plan_name: 'Startups',
activated_at: account.created_at + 1.day,
conversion_value: 398.0,
currency_code: 'USD'
).perform
expect(Internal::Accounts::MarketingConversionTrackingJob).to have_been_enqueued.with(
account.id,
'cloud_plan_activation',
account.created_at + 1.day,
398.0,
'USD'
)
expect(account.reload.internal_attributes.dig('marketing_attribution', described_class::PLAN_ACTIVATION_TRACKED_AT)).to be_present
end
it 'does not enqueue conversion tracking when plan activation was already tracked' do
account.update!(
internal_attributes: {
'marketing_attribution' => {
'last_touch' => { 'gclid' => 'test-click-id' },
described_class::PLAN_ACTIVATION_TRACKED_AT => 1.day.ago.iso8601
}
}
)
described_class.new(
account: account,
previous_plan_name: 'Hacker',
current_plan_name: 'Startups',
activated_at: account.created_at + 1.day,
conversion_value: 398.0,
currency_code: 'USD'
).perform
expect(Internal::Accounts::MarketingConversionTrackingJob).not_to have_been_enqueued
end
it 'does not enqueue conversion tracking outside the signup attribution window' do
described_class.new(
account: account,
previous_plan_name: 'Hacker',
current_plan_name: 'Startups',
activated_at: account.created_at + 31.days,
conversion_value: 398.0,
currency_code: 'USD'
).perform
expect(Internal::Accounts::MarketingConversionTrackingJob).not_to have_been_enqueued
end
end