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