Class: Facebook::ConversionReporter
- Inherits:
-
BaseService
- Object
- BaseService
- Facebook::ConversionReporter
- 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_idso the browser pixel (fbq)
and the CAPI call dedupe correctly via Meta'sevent_idmatching - 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 =
Meta standard event names — see
https://developers.facebook.com/docs/meta-pixel/reference#standard-events 'Purchase'- EVENT_LEAD =
Event lead.
'Lead'
Instance Attribute Summary
Attributes inherited from BaseService
Instance Method Summary collapse
-
#send_events(events) ⇒ Object
── API Communication ────────────────────────────────────────────────.
-
#send_opportunity_conversion(opportunity) ⇒ Object
── Opportunity Conversions ──────────────────────────────────────────.
-
#send_order_conversion(order) ⇒ Object
── Order Conversions ────────────────────────────────────────────────.
-
#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.
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..present? Rails.logger.warn "Facebook::ConversionReporter: Opportunity #{opportunity.reference_number} already reported" return { success: false, reason: :already_reported, meta: opportunity. } 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]) (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..present? Rails.logger.warn "Facebook::ConversionReporter: Order #{order.reference_number} already reported" return { success: false, reason: :already_reported, meta: order. } 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]) (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.
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 |