From cd1a214d980dc5a21173923c3e79d5a3771371ec Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 24 Jun 2026 18:27:02 -0700 Subject: [PATCH] chore: Add marketing conversion tracking foundation (#14851) ## Summary Adds the minimal foundation for Cloud marketing conversion tracking: - locked internal installation config for conversion tracking credentials and event mappings - generic background job and service for uploading conversion events from stored attribution - focused coverage for Cloud gating, click-id selection, payload shape, and optional conversion value This PR intentionally does not wire signup or plan activation yet. The next step is to validate the service from Rails console against existing attributed accounts, then add the event hooks in a follow-up PR. ## Notes The config remains locked and is surfaced under the Internal settings group next to the existing Cloud plan configuration. The service assumes the locked config is present and valid; config mistakes should surface instead of being silently ignored. --- AGENTS.md | 11 +- config/installation_config.yml | 6 + .../super_admin/app_configs_controller.rb | 6 +- .../marketing_conversion_tracking_job.rb | 15 +++ .../marketing_conversion_tracking_service.rb | 99 +++++++++++++++ ...keting_conversion_tracking_service_spec.rb | 119 ++++++++++++++++++ 6 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 enterprise/app/jobs/internal/accounts/marketing_conversion_tracking_job.rb create mode 100644 enterprise/app/services/internal/accounts/marketing_conversion_tracking_service.rb create mode 100644 spec/enterprise/services/internal/accounts/marketing_conversion_tracking_service_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 2ab6373b7..2430fae2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,18 @@ ## General Guidelines -- MVP focus: Least code change, happy-path only -- No unnecessary defensive programming -- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer the smallest production-ready change that solves the current problem. +- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. +- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. +- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks. +- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully. +- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing. +- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read. - Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked +- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity. - Remove dead/unreachable/unused code - Don’t write multiple versions or backups for the same logic — pick the best approach and implement it - Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs diff --git a/config/installation_config.yml b/config/installation_config.yml index 673c6df4c..43fc6bb97 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -253,6 +253,12 @@ display_title: 'Cloud Plans' value: description: 'Config to store stripe plans for cloud' +- name: MARKETING_CONVERSION_TRACKING_CONFIG + value: + display_title: 'Marketing Conversion Tracking Config' + description: 'JSON config for Chatwoot Cloud signup and plan activation conversion tracking' + locked: true + type: code - name: CHATWOOT_CLOUD_PLAN_FEATURES display_title: 'Planwise Features List' value: diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index f91f12708..0a9807eb0 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -35,9 +35,9 @@ module Enterprise::SuperAdmin::AppConfigsController def internal_config_options %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY CONTEXT_DEV_API_KEY DASHBOARD_SCRIPTS - INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL - CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS - OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL] + INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS MARKETING_CONVERSION_TRACKING_CONFIG + ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY + CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL] end def captain_config_options diff --git a/enterprise/app/jobs/internal/accounts/marketing_conversion_tracking_job.rb b/enterprise/app/jobs/internal/accounts/marketing_conversion_tracking_job.rb new file mode 100644 index 000000000..239620899 --- /dev/null +++ b/enterprise/app/jobs/internal/accounts/marketing_conversion_tracking_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Internal::Accounts::MarketingConversionTrackingJob < ApplicationJob + queue_as :purgable + + def perform(account_id, event_name, occurred_at = nil, conversion_value = nil, currency_code = nil) + Internal::Accounts::MarketingConversionTrackingService.new( + account: Account.find(account_id), + event_name: event_name, + occurred_at: occurred_at, + conversion_value: conversion_value, + currency_code: currency_code + ).perform + end +end diff --git a/enterprise/app/services/internal/accounts/marketing_conversion_tracking_service.rb b/enterprise/app/services/internal/accounts/marketing_conversion_tracking_service.rb new file mode 100644 index 000000000..41d3814dd --- /dev/null +++ b/enterprise/app/services/internal/accounts/marketing_conversion_tracking_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'googleauth' + +class Internal::Accounts::MarketingConversionTrackingService + CONFIG_KEY = 'MARKETING_CONVERSION_TRACKING_CONFIG' + # Expected config shape: + # { + # "customer_id": "123-456-7890", + # "login_customer_id": "123-456-7890", + # "service_account_credentials": { ... }, + # "events": { + # "cloud_signup": { "conversion_action_id": "123456789" }, + # "cloud_plan_activation": { "conversion_action_id": "987654321" } + # } + # } + TOKEN_SCOPES = ['https://www.googleapis.com/auth/datamanager'].freeze + API_URL = 'https://datamanager.googleapis.com/v1/events:ingest' + CLICK_ID_FIELDS = %w[gclid gbraid wbraid].freeze + + pattr_initialize [:account!, :event_name!, :occurred_at, :conversion_value, :currency_code] + + def perform + return unless ChatwootApp.chatwoot_cloud? + return if click_attributes.blank? + + response = HTTParty.post( + API_URL, + headers: { + 'Authorization' => "Bearer #{access_token}", + 'Content-Type' => 'application/json' + }, + body: { + destinations: [destination_payload], + events: [conversion_payload] + }.to_json + ) + + raise "Marketing conversion upload failed: #{response.body}" unless response.success? + end + + private + + def destination_payload + { + operatingAccount: { + accountType: 'GOOGLE_ADS', + accountId: config['customer_id'].delete('-') + }, + loginAccount: { + accountType: 'GOOGLE_ADS', + accountId: config['login_customer_id'].delete('-') + }, + productDestinationId: config['events'][event_name]['conversion_action_id'] + } + end + + def conversion_payload + payload = { + transactionId: "#{event_name}-account-#{account.id}", + eventTimestamp: (occurred_at.present? ? Time.zone.parse(occurred_at.to_s) : Time.current).iso8601, + eventSource: 'WEB', + adIdentifiers: click_attributes + } + + if conversion_value.present? + payload[:conversionValue] = conversion_value.to_f + payload[:currency] = currency_code.presence || 'USD' + end + + payload + end + + def click_attributes + @click_attributes ||= CLICK_ID_FIELDS.filter_map do |field| + value = attribution[field] + [field.to_sym, value] if value.present? + end.to_h + end + + def attribution + marketing_attribution = account.internal_attributes['marketing_attribution'] || {} + [marketing_attribution['last_touch'], marketing_attribution['first_touch']].find do |touch| + touch.present? && CLICK_ID_FIELDS.any? { |field| touch[field].present? } + end || {} + end + + def access_token + authorizer = Google::Auth::ServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(config['service_account_credentials'].to_json), + scope: TOKEN_SCOPES + ) + authorizer.fetch_access_token!['access_token'] + end + + def config + @config ||= JSON.parse(InstallationConfig.find_by!(name: CONFIG_KEY).value) + end +end diff --git a/spec/enterprise/services/internal/accounts/marketing_conversion_tracking_service_spec.rb b/spec/enterprise/services/internal/accounts/marketing_conversion_tracking_service_spec.rb new file mode 100644 index 000000000..f1b6578d6 --- /dev/null +++ b/spec/enterprise/services/internal/accounts/marketing_conversion_tracking_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Internal::Accounts::MarketingConversionTrackingService do + let(:account) { create(:account) } + let(:event_name) { 'cloud_signup' } + let(:occurred_at) { '2026-06-23T10:30:00Z' } + let(:private_key) { OpenSSL::PKey::RSA.new(2048).to_pem } + let(:credentials) do + instance_double(Google::Auth::ServiceAccountCredentials, fetch_access_token!: { 'access_token' => 'access-token' }) + end + let(:config) do + { + 'customer_id' => '852-320-2898', + 'login_customer_id' => '742-202-9198', + 'service_account_credentials' => { + 'client_email' => 'marketing-conversions@chatwoot-production.iam.gserviceaccount.com', + 'private_key' => private_key + }, + 'events' => { + 'cloud_signup' => { + 'conversion_action_id' => '123456789' + } + } + } + end + let(:marketing_attribution) do + { + 'first_touch' => { 'gclid' => 'first-click' }, + 'last_touch' => { 'gclid' => 'last-click' } + } + end + + before do + create(:installation_config, name: described_class::CONFIG_KEY, value: config.to_json) + account.update!(internal_attributes: { 'marketing_attribution' => marketing_attribution }) + + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) + allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds).and_return(credentials) + end + + it 'does nothing outside Chatwoot Cloud' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + + expect(HTTParty).not_to receive(:post) + + described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform + end + + it 'uploads the last-touch click conversion', :aggregate_failures do + upload_request = nil + + allow(HTTParty).to receive(:post) do |url, options| + upload_request = [url, options] + instance_double(HTTParty::Response, success?: true, body: '{}') + end + + described_class.new( + account: account, + event_name: event_name, + occurred_at: occurred_at, + conversion_value: 199, + currency_code: 'USD' + ).perform + + url, options = upload_request + body = JSON.parse(options[:body]) + + expect(url).to eq('https://datamanager.googleapis.com/v1/events:ingest') + expect(Google::Auth::ServiceAccountCredentials).to have_received(:make_creds).with( + json_key_io: kind_of(StringIO), + scope: ['https://www.googleapis.com/auth/datamanager'] + ) + expect(options[:headers]).to include( + 'Authorization' => 'Bearer access-token' + ) + expect(body['destinations'].first).to include( + 'operatingAccount' => { + 'accountType' => 'GOOGLE_ADS', + 'accountId' => '8523202898' + }, + 'loginAccount' => { + 'accountType' => 'GOOGLE_ADS', + 'accountId' => '7422029198' + }, + 'productDestinationId' => '123456789' + ) + expect(body['events'].first).to include( + 'transactionId' => "cloud_signup-account-#{account.id}", + 'eventTimestamp' => '2026-06-23T10:30:00Z', + 'eventSource' => 'WEB', + 'adIdentifiers' => { 'gclid' => 'last-click' }, + 'conversionValue' => 199.0, + 'currency' => 'USD' + ) + end + + it 'falls back to first-touch attribution when last-touch attribution has no click id' do + account.update!( + internal_attributes: { + 'marketing_attribution' => { + 'last_touch' => { 'source' => 'github' }, + 'first_touch' => { 'gclid' => 'first-click' } + } + } + ) + upload_body = nil + + allow(HTTParty).to receive(:post) do |_url, options| + upload_body = JSON.parse(options[:body]) + instance_double(HTTParty::Response, success?: true, body: '{}') + end + + described_class.new(account: account, event_name: event_name, occurred_at: occurred_at).perform + + expect(upload_body['events'].first['adIdentifiers']['gclid']).to eq('first-click') + end +end