Class: OpenaiAds::ConversionReporter

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

Overview

OpenAI Ads (ChatGPT) Conversions API — server-to-server event reporting.

Mirrors Pinterest::ConversionReporter and Invoicing::GoogleConversionReporter:

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

Canary rollout via validate_only

First deploy ships with Heatwave::Configuration.fetch(:openai_ads, :validate_only)
set to true. OpenAI validates the payload schema but doesn't count events
toward attribution — we use this to confirm our user object shape and
event fields are accepted before flipping to real sends.

Constant Summary collapse

EVENT_ORDER_CREATED =

Standard order conversion event name.

'order_created'
EVENT_LEAD_CREATED =

Standard lead conversion event name.

'lead_created'

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) ⇒ Hash

Sends one or more conversion events to OpenAI Ads.

Parameters:

  • events (Array<Hash>)

    conversion event payloads

Returns:

  • (Hash)

    normalized API result



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'app/services/openai_ads/conversion_reporter.rb', line 214

def send_events(events)
  result = OpenaiAds::ApiClient.new.send_events(
    pixel_id:      pixel_id,
    token:         capi_token,
    events:        events,
    validate_only: validate_only?
  )

  if result[:status] == :failed
    if result[:timeout]
      # Transient timeout to an intermittently-slow endpoint. The record
      # keeps result: 'failed', so ConversionRetrySweepWorker re-enqueues it
      # within its 7-day window — don't page AppSignal for each attempt
      # (#5214; the sweep's own contract is to keep this noise out of AppSignal).
      Rails.logger.warn("OpenaiAds::ConversionReporter: #{result[:error]} (will retry via sweep)")
    else
      ErrorReporting.error("OpenaiAds::ConversionReporter: #{result[:error]}", {
        http_status: result[:http_status],
        event_count: events.size
      })
    end
  end

  result
end

#send_opportunity_conversion(opportunity) ⇒ Hash

Reports an attributed sales opportunity to the OpenAI Ads Conversions API.

Parameters:

  • opportunity (Opportunity)

    sales opportunity to report

Returns:

  • (Hash)

    reporting result and persisted conversion metadata



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'app/services/openai_ads/conversion_reporter.rb', line 103

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

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

  if already_validate_only_ok?(opportunity.openai_ads_conversion_meta)
    Rails.logger.info "OpenaiAds::ConversionReporter: Opportunity #{opportunity.reference_number} already validated (canary); skipping re-send"
    return { success: false, reason: :already_validate_only_ok, meta: opportunity.openai_ads_conversion_meta }
  end

  if (sibling = sibling_opportunity_already_reported(opportunity))
    Rails.logger.info "OpenaiAds::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
  customer             = opportunity.customer
  email                = opportunity.emails.pick(:detail) || customer&.email
  visit                = find_visit(opportunity)

  # CAPI exists to enrich pixel data — `action_source: 'web'` events without
  # a `source_url` are rejected by OpenAI (`source_url_required_for_web`),
  # and a CRM-rep-created opp with no visit never came from a web ad anyway.
  # Skip cleanly rather than ship a noisy event with a synthetic URL.
  skip_result = skip_unreportable(opportunity, visit, customer)
  return skip_result if skip_result

  event_id = opportunity.tracking_event_id.presence || SecureRandom.uuid

  event = build_event(
    event_name:    EVENT_LEAD_CREATED,
    event_time:    conversion_date_time,
    event_id:      event_id,
    action_source: 'web',
    source_url:    visit&.landing_page,
    oppref:        visit&.marketing_meta&.[]('oppref').presence,
    obref:         visit&.marketing_meta&.[]('obref').presence,
    external_id:   customer.id.to_s,
    email:         email,
    ip:            visit&.ip,
    user_agent:    visit&.user_agent,
    **opportunity_geo(opportunity, visit),
    data: {
      type:     'customer_action',
      amount:   (fractional_value.to_f * 100).round,
      currency: 'USD'
    }.compact
  )

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

#send_order_conversion(order) ⇒ Hash

Reports an attributed sales order to the OpenAI Ads Conversions API.

Parameters:

  • order (Order)

    sales order to report

Returns:

  • (Hash)

    reporting result and persisted conversion metadata



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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'app/services/openai_ads/conversion_reporter.rb', line 36

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

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

  if already_validate_only_ok?(order.openai_ads_conversion_meta)
    Rails.logger.info "OpenaiAds::ConversionReporter: Order #{order.reference_number} already validated (canary); skipping re-send"
    return { success: false, reason: :already_validate_only_ok, meta: order.openai_ads_conversion_meta }
  end

  conversion_date_time = order.invoices.first&.created_at
  unless conversion_date_time
    Rails.logger.warn "OpenaiAds::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)
  customer = order.customer

  # CAPI exists to enrich pixel data — `action_source: 'web'` events without
  # a `source_url` are rejected by OpenAI (`source_url_required_for_web`),
  # and an order with no visit on itself, its quote, or its opportunity
  # never came from a web ad anyway. Skip cleanly rather than ship a noisy
  # event with a synthetic URL.
  skip_result = skip_unreportable(order, visit, customer)
  return skip_result if skip_result

  # Shared with Pinterest + browser pixel — see PR #792 description.
  event_id = order.tracking_event_id.presence || SecureRandom.uuid

  event = build_event(
    event_name:    EVENT_ORDER_CREATED,
    event_time:    conversion_date_time,
    event_id:      event_id,
    action_source: 'web',
    source_url:    visit&.landing_page,
    oppref:        visit&.marketing_meta&.[]('oppref').presence,
    obref:         visit&.marketing_meta&.[]('obref').presence,
    external_id:   customer.id.to_s,
    email:         email,
    ip:            visit&.ip,
    user_agent:    visit&.user_agent,
    **order_geo(order, visit),
    data: {
      type:     'contents',
      # OpenAI expects monetary values in integer minor units (cents).
      amount:   (total.to_f * 100).round,
      currency: 'USD',
      contents: build_contents(order.line_items)
    }.compact
  )

  result = send_events([event])
  persist_meta(order, result, event_id, EVENT_ORDER_CREATED, 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 OpenAI
session. Mirrors Invoicing::GoogleConversionReporter#sibling_opportunity_already_reported
and Pinterest::ConversionReporter#sibling_opportunity_already_reported.

Parameters:

Returns:

  • (Opportunity, nil)

    sibling already reported, or nil.



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'app/services/openai_ads/conversion_reporter.rb', line 170

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("openai_ads_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