Class: Facebook::ConversionReporter

Inherits:
BaseService show all
Defined in:
app/services/facebook/conversion_reporter.rb

Overview

Facebook/Meta Conversions API (CAPI) — server-to-server event reporting.

Mirrors OpenaiAds::ConversionReporter, Pinterest::ConversionReporter,
and Invoicing::GoogleConversionReporter:

  • called from FacebookConversionWorker
  • persists result metadata to the record's facebook_conversion_meta
    JSONB column (used as the idempotency key and for sibling-opp dedup)
  • reuses the shared tracking_event_id so the browser pixel (fbq)
    and the CAPI call dedupe correctly via Meta's event_id matching
  • routes PII through Tracking::Hashing (SHA-256, lowercased + trimmed,
    gmail dot-stripping for higher match rates)

Canary rollout via test_event_code

First deploy ships with Heatwave::Configuration.fetch(:facebook, :test_event_code) set to a Meta-issued test code (visible under Events
Manager → Test Events). Meta routes events with this code to the test
tab and doesn't count them toward attribution — we use this to confirm
our user_data shape and event fields are accepted before flipping to
real sends. Set the code to nil/blank in production once verified.

Constant Summary collapse

EVENT_PURCHASE =
'Purchase'
EVENT_LEAD =

Event lead.

'Lead'

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#send_events(events) ⇒ Object

── API Communication ────────────────────────────────────────────────



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'app/services/facebook/conversion_reporter.rb', line 159

def send_events(events)
  result = Facebook::ApiClient.new.send_events(
    pixel_id:        pixel_id,
    token:           capi_token,
    events:          events,
    test_event_code: test_event_code
  )

  if result[:status] == :failed
    ErrorReporting.error("Facebook::ConversionReporter: #{result[:error]}", {
      http_status:   result[:http_status],
      error_code:    result[:error_code],
      error_subcode: result[:error_subcode],
      fbtrace_id:    result[:fbtrace_id],
      event_count:   events.size
    })
  end

  result
end

#send_opportunity_conversion(opportunity) ⇒ Object

── Opportunity Conversions ──────────────────────────────────────────



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'app/services/facebook/conversion_reporter.rb', line 86

def send_opportunity_conversion(opportunity)
  return { success: false, reason: :not_a_sales_opportunity } unless opportunity.sales_opportunity?
  return { success: false, reason: :credentials_missing }     unless credentials_present?

  if opportunity.facebook_conversion_meta_reported_at.present?
    Rails.logger.warn "Facebook::ConversionReporter: Opportunity #{opportunity.reference_number} already reported"
    return { success: false, reason: :already_reported, meta: opportunity.facebook_conversion_meta }
  end

  if (sibling = sibling_opportunity_already_reported(opportunity))
    Rails.logger.info "Facebook::ConversionReporter: Sibling opp #{sibling.reference_number} already reported; skipping #{opportunity.reference_number}"
    return { success: false, reason: :sibling_already_reported, sibling_opportunity_id: sibling.id }
  end

  conversion_date_time = opportunity.updated_at || opportunity.created_at
  fractional_value     = (opportunity.value || 0) * 0.2 # Same AVG_LEAD_CONVERSION_RATE as Google/Pinterest/OpenAI
  email                = opportunity.emails.pick(:detail) || opportunity.customer&.email
  visit                = find_visit(opportunity)
  event_id             = opportunity.tracking_event_id.presence || SecureRandom.uuid

  event = build_event(
    event_name:  EVENT_LEAD,
    event_time:  conversion_date_time,
    event_id:    event_id,
    source_url:  visit&.landing_page,
    external_id: opportunity.customer&.id&.to_s,
    email:       email,
    ip:          visit&.ip,
    user_agent:  visit&.user_agent,
    custom_data: {
      currency:     'USD',
      value:        fractional_value.to_f.round(2),
      content_name: opportunity.reference_number
    }.compact
  )

  result = send_events([event])
  persist_meta(opportunity, result, event_id, EVENT_LEAD, conversion_date_time, email)
end

#send_order_conversion(order) ⇒ Object

── Order Conversions ────────────────────────────────────────────────



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'app/services/facebook/conversion_reporter.rb', line 37

def send_order_conversion(order)
  return { success: false, reason: :not_a_sales_order }    unless order.is_sales_order?
  return { success: false, reason: :credentials_missing }  unless credentials_present?

  if order.facebook_conversion_meta_reported_at.present?
    Rails.logger.warn "Facebook::ConversionReporter: Order #{order.reference_number} already reported"
    return { success: false, reason: :already_reported, meta: order.facebook_conversion_meta }
  end

  conversion_date_time = order.invoices.first&.created_at
  unless conversion_date_time
    Rails.logger.warn "Facebook::ConversionReporter: No invoice for order #{order.reference_number}"
    return { success: false, reason: :no_invoice }
  end

  total    = order.invoices.sum(&:revenue_consolidated)
  email    = order.tracking_email&.first || order.order_emails.first
  visit    = find_visit(order)
  # Shared with the browser fbq pixel — see PR description for analytics.js
  # Purchase eventID wiring. Falls back to a fresh UUID for legacy orders
  # that predate the column (those events were already pixel-only with
  # their own ids, no dedup loss).
  event_id = order.tracking_event_id.presence || SecureRandom.uuid

  event = build_event(
    event_name:  EVENT_PURCHASE,
    event_time:  conversion_date_time,
    event_id:    event_id,
    source_url:  visit&.landing_page,
    external_id: order.customer&.id&.to_s,
    email:       email,
    ip:          visit&.ip,
    user_agent:  visit&.user_agent,
    custom_data: {
      currency:     'USD',
      value:        total.to_f.round(2),
      order_id:     order.reference_number,
      content_type: 'product',
      content_ids:  build_content_ids(order.line_items),
      num_items:    order.line_items.size
    }.compact
  )

  result = send_events([event])
  persist_meta(order, result, event_id, EVENT_PURCHASE, conversion_date_time, email)
end

#sibling_opportunity_already_reported(opportunity) ⇒ Opportunity?

When a rep creates a CRM opp for a customer who already had an open
quote-builder / online opp, both opps can transition to qualified and
both would fire send_opportunity_conversion against the same Facebook
session — phantom lead volume that biases Meta's smart bidding.
Mirrors Invoicing::GoogleConversionReporter#sibling_opportunity_already_reported,
Pinterest::ConversionReporter#sibling_opportunity_already_reported,
and OpenaiAds::ConversionReporter#sibling_opportunity_already_reported.

Public so tests (and external auditing) can probe it without .send.

Parameters:

Returns:

  • (Opportunity, nil)

    sibling already reported, or nil.



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'app/services/facebook/conversion_reporter.rb', line 140

def sibling_opportunity_already_reported(opportunity)
  customer_id = opportunity.customer_id
  return nil if customer_id.nil?

  cluster_ids = [opportunity.parent_id, opportunity.merged_into_id, opportunity.id].compact

  Opportunity
    .where(customer_id: customer_id)
    .where.not(id: opportunity.id)
    .where("facebook_conversion_meta->>'reported_at' IS NOT NULL")
    .where(
      'opportunities.id IN (?) OR opportunities.parent_id IN (?) OR opportunities.merged_into_id IN (?)',
      cluster_ids, cluster_ids, cluster_ids
    )
    .first
end