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:
@@ -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))
|
||||
|
||||
+75
@@ -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
|
||||
Reference in New Issue
Block a user