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 ────────────────────────────────────────────────



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'app/services/facebook/conversion_reporter.rb', line 156

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
    if result[:transient]
      # Transient Meta-side blip (HTTP 5xx, or Graph error code 1/2 —
      # "An unknown error has occurred."). The record keeps result: 'failed'
      # with reported_at: nil, so ConversionRetrySweepWorker re-enqueues it
      # within its 7-day window. Don't page AppSignal for each attempt —
      # these are not our defect (AppSignal #5248; mirrors the OpenAI Ads
      # transient-timeout contract in #5214).
      Rails.logger.warn(
        "Facebook::ConversionReporter: transient Meta error -- #{result[:error]} " \
        "(code=#{result[:error_code]} http=#{result[:http_status]}); will retry via sweep"
      )
    else
      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
  end

  result
end

#send_opportunity_conversion(opportunity) ⇒ Object

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



84
85
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
# File 'app/services/facebook/conversion_reporter.rb', line 84

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) * Setting.ad_conversion_opportunity_rate # PPC dial — /crm/settings/ad-conversion-values
  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 ────────────────────────────────────────────────



36
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
# File 'app/services/facebook/conversion_reporter.rb', line 36

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.



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

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