Class: MicrosoftAds::ConversionReporter

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

Overview

Microsoft Advertising (Bing Ads) offline-conversion reporting via the
Campaign Management v13 ApplyOfflineConversions operation.

Mirrors OpenaiAds::ConversionReporter / Pinterest::ConversionReporter /
Invoicing::GoogleConversionReporter:

  • called from MicrosoftAdsConversionWorker
  • persists result metadata to the record's microsoft_ads_conversion_meta
    JSONB column (the idempotency key and the sibling-opp dedup scope)
  • keyed by the Microsoft Click Id (msclkid) captured into
    Visit#marketing_meta — the join key Microsoft attributes and dedupes
    on, so a record with no resolvable msclkid never came from a Microsoft
    ad and is skipped cleanly (like Google's Offline Conversion Import, and
    unlike OpenAI's visit-keyed CAPI).

Microsoft returns HTTP 200 even for rejected items and reports per-item
failures in PartialErrors; ConversionsClient folds that into
a terminal :rejected status (vs. retryable :rate_limited / :failed).

Rollout gate (no validate_only analog)

Heatwave::Configuration.fetch(:microsoft_ads, :conversions_enabled) must be
true for any send. ApplyOfflineConversions has no schema-validation mode, so
this gate is the safety valve: ship the code, create the OfflineConversionGoals
(Microsoft needs ~2 hours to propagate it), then flip the gate. While disabled
every call returns :disabled without a send or a persisted attempt — backfill
the brief disabled window by re-enqueuing recent records once enabled (the
offline window is 90 days).

Constant Summary collapse

DEFAULT_PURCHASE_CONVERSION_NAME =

Default OfflineConversionGoal names. Orders are purchases; opportunities are
quote requests — they map to two separate Microsoft goals (Purchase category
/ Request-quote category) so opportunities aren't miscounted as purchases.
Each must match a goal created on the account, overridable per-env via the
microsoft_ads.offline_purchase_name / microsoft_ads.offline_opportunity_name
credentials.

'WarmlyYours Offline Purchase'
DEFAULT_OPPORTUNITY_CONVERSION_NAME =
'WarmlyYours Offline Opportunity'
WINDOW_REJECTION_CODE =

Microsoft's PartialError code for a conversion whose click is older than the
goal's 90-day conversion window. Expected and non-actionable — you can't make
a click younger — so it is logged at info rather than reported to AppSignal
(unlike an unknown goal name or a malformed time, which are real defects).
Common for late conversions and the 90-day backfill.

'ClickIdDateTimeOutsideGoalConversionWindow'

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_conversions(conversions) ⇒ Object

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



144
145
146
147
148
149
150
151
152
153
154
155
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
# File 'app/services/microsoft_ads/conversion_reporter.rb', line 144

def send_conversions(conversions)
  result = client.apply_offline_conversions(conversions: conversions)

  case result[:status]
  when :failed
    if result[:timeout]
      # Transient timeout — the record keeps result: 'failed' so
      # ConversionRetrySweepWorker re-enqueues it within its window; don't
      # page AppSignal for each attempt.
      Rails.logger.warn("MicrosoftAds::ConversionReporter: #{result[:error]} (will retry via sweep)")
    else
      ErrorReporting.error("MicrosoftAds::ConversionReporter: #{result[:error]}", {
        http_status:      result[:http_status],
        conversion_count: conversions.size
      })
    end
  when :rejected
    # Permanent per-item rejection. persist_meta stamps a terminal result, NOT
    # 'failed', so the sweep doesn't loop on an unfixable payload either way.
    if expected_window_rejection?(result)
      # Click is older than the goal's 90-day window
      # (ClickIdDateTimeOutsideGoalConversionWindow): expected and not
      # actionable, so log at info instead of paging AppSignal. Common for
      # late conversions and the 90-day backfill.
      Rails.logger.info("MicrosoftAds::ConversionReporter: click outside 90-day conversion window; skipping -- #{result[:error]}")
    else
      # Genuine defect (unknown goal name, malformed time, wrong account) —
      # surface for a human to fix.
      ErrorReporting.error("MicrosoftAds::ConversionReporter: rejected -- #{result[:error]}", {
        http_status:    result[:http_status],
        partial_errors: result[:partial_errors]
      })
    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
# File 'app/services/microsoft_ads/conversion_reporter.rb', line 84

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

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

  if (sibling = sibling_opportunity_already_reported(opportunity))
    Rails.logger.info "MicrosoftAds::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

  msclkid = opportunity.find_msclkid
  unless msclkid
    Rails.logger.info "MicrosoftAds::ConversionReporter: Opportunity #{opportunity.reference_number} has no msclkid (no Microsoft Ads attribution); skipping"
    return persist_skip_meta(opportunity, :no_msclkid)
  end

  conversion_date_time = persisted_conversion_date_time(opportunity) ||
                         opportunity.updated_at || opportunity.created_at
  fractional_value     = (opportunity.value || 0) * Setting.ad_conversion_opportunity_rate
  email                = opportunity.emails.pick(:detail) || opportunity.customer&.email

  conversion = build_conversion(msclkid: msclkid, conversion_date_time: conversion_date_time,
                                value: fractional_value, conversion_name: opportunity_conversion_name, email: email)

  result = send_conversions([conversion])
  persist_meta(opportunity, result, msclkid, conversion_date_time, opportunity_conversion_name)
end

#send_order_conversion(order) ⇒ Object

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



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/microsoft_ads/conversion_reporter.rb', line 49

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

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

  msclkid = order.find_msclkid
  unless msclkid
    # No Microsoft click id on any attributable visit — the offline conversion
    # has no join key and can never be applied. Terminal skip (lifts the
    # record out of ConversionRetrySweepWorker's `result = 'failed'` filter).
    Rails.logger.info "MicrosoftAds::ConversionReporter: Order #{order.reference_number} has no msclkid (no Microsoft Ads attribution); skipping"
    return persist_skip_meta(order, :no_msclkid)
  end

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

  conversion = build_conversion(msclkid: msclkid, conversion_date_time: conversion_date_time,
                                value: total, conversion_name: purchase_conversion_name, email: email)

  result = send_conversions([conversion])
  persist_meta(order, result, msclkid, conversion_date_time, purchase_conversion_name)
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 Microsoft click.
Mirrors OpenaiAds::ConversionReporter#sibling_opportunity_already_reported
and Invoicing::GoogleConversionReporter#sibling_opportunity_already_reported.

Parameters:

Returns:

  • (Opportunity, nil)

    sibling already reported, or nil.



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'app/services/microsoft_ads/conversion_reporter.rb', line 126

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