diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 9760caacf..8342343e9 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -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 diff --git a/enterprise/app/services/internal/accounts/cloud_plan_activation_conversion_service.rb b/enterprise/app/services/internal/accounts/cloud_plan_activation_conversion_service.rb new file mode 100644 index 000000000..0421609fe --- /dev/null +++ b/enterprise/app/services/internal/accounts/cloud_plan_activation_conversion_service.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index f9b550ef8..3223efa86 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -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)) diff --git a/spec/enterprise/services/internal/accounts/cloud_plan_activation_conversion_service_spec.rb b/spec/enterprise/services/internal/accounts/cloud_plan_activation_conversion_service_spec.rb new file mode 100644 index 000000000..cf7d7419f --- /dev/null +++ b/spec/enterprise/services/internal/accounts/cloud_plan_activation_conversion_service_spec.rb @@ -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