Class: MicrosoftAds::ConversionReporter
- Inherits:
-
BaseService
- Object
- BaseService
- MicrosoftAds::ConversionReporter
- 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 resolvablemsclkidnever 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
Instance Method Summary collapse
-
#send_conversions(conversions) ⇒ 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 Microsoft click.
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..present? Rails.logger.warn "MicrosoftAds::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 "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 (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]) (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..present? Rails.logger.warn "MicrosoftAds::ConversionReporter: Order #{order.reference_number} already reported" return { success: false, reason: :already_reported, meta: order. } 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 (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]) (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.
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 |