Class: Catalog::AmazonPriceLoweringService

Inherits:
BaseService
  • Object
show all
Defined in:
app/services/catalog/amazon_price_lowering_service.rb

Overview

Service responsible for lowering Amazon prices to win the Buy Box.
This service handles all competitive pricing logic:

  • Lowering prices to beat seller competitors
  • Matching external competitor thresholds (Amazon's competitive pricing signals)
  • Probing for Buy Box when no competitor data exists (gradual price reduction)
  • Flagging items that cannot automatically win

Used by:

  • AmazonPricingAutomationService (nightly full run)
  • AmazonBuyBoxService (corrective action the moment the Buy Box is lost)

Defined Under Namespace

Classes: Result

Constant Summary collapse

COMPETITIVE_PRICE_THRESHOLD_PERCENT =

Maximum automatic price change percentage (5%)

0.05
PROBING_COOLDOWN_HOURS =

Minimum time between probing price reductions (24 hours)
This prevents rapid price drops when Amazon isn't giving us competitive data

24
AMAZON_SELLER_IDS =

Amazon's own seller IDs (US and Canada) - we don't compete with Amazon on price
because we want them to deplete their Vendor Central inventory

%w[
  A2R2RITDJNW1Q6
  A3DWYIK6Y9EEQB
].freeze

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, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#amazon_seller?(seller_id) ⇒ Boolean

Check if seller ID is Amazon (US or Canada)

Returns:

  • (Boolean)


178
179
180
181
182
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 178

def amazon_seller?(seller_id)
  return false if seller_id.blank?

  AMAZON_SELLER_IDS.include?(seller_id)
end

#compete_toward(catalog_item, target_price, source:, competitor_seller_id: nil, competitor_label: nil, actor: nil) ⇒ Result

Step this listing toward a target chosen by the caller, using the same
undercut / max-step / floor / flag mechanics as #process.

Catalog::AmazonBuyBoxService owns signal selection (Amazon's competitive
threshold, a competing seller offer, a fresh external retailer price) and
hands the winning target here; this service owns how the price gets there.

Parameters:

  • catalog_item (CatalogItem)
  • target_price (BigDecimal, Float)

    tax-inclusive price to compete toward

  • source (Symbol)

    the ladder rung that produced the target

  • competitor_seller_id (String, nil) (defaults to: nil)

    recorded on a cannot-win flag

Returns:



252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 252

def compete_toward(catalog_item, target_price, source:, competitor_seller_id: nil, competitor_label: nil, actor: nil)
  with_actor(actor) do
    compete_toward_target(
      catalog_item,
      target_price,
      catalog_item.amazon_price_with_tax,
      catalog_item.amazon_repricing_floor_with_tax,
      is_external: source != :amazon_offer,
      competitor_seller_id: competitor_seller_id || source.to_s.upcase,
      competitor_label: competitor_label
    )
  end
end

#eligible_for_buy_box_probing?(catalog_item) ⇒ Boolean

Check if we're eligible for Buy Box probing
Conditions:

  1. We're the Featured Merchant (eligible for Buy Box) but not winning
  2. We haven't lowered price recently (within PROBING_COOLDOWN_HOURS)
  3. We're above minimum price (have room to lower)

Returns:

  • (Boolean)


189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 189

def eligible_for_buy_box_probing?(catalog_item)
  # Must be Featured Merchant but not Buy Box Winner
  return false unless catalog_item.is_amz_featured_merchant
  return false if catalog_item.is_amz_buy_box_winner

  # Must not have lowered price recently
  return false if recently_lowered_price?(catalog_item)

  # Must have room to lower (above minimum)
  our_price = catalog_item.amazon_price_with_tax
  min_price = catalog_item.amazon_repricing_floor_with_tax
  return false if our_price <= min_price

  true
end

#get_competitive_price(catalog_item) ⇒ Object

Get the best competitive price from Buy Box status data
Returns hash with :price and :seller_id, or nil



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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 92

def get_competitive_price(catalog_item)
  ecl = catalog_item.edi_communication_logs
                    .where(category: :buy_box_status)
                    .where(state: 'processed')
                    .order(created_at: :desc)
                    .first

  return nil if ecl&.data.blank?

  begin
    json_hash = JSON.parse(ecl.data).with_indifferent_access
    offers = json_hash.dig(:payload, :Offers) || []
    return nil if offers.empty?

    offers = offers.map(&:with_indifferent_access)
    merchant_id = catalog_item.catalog.load_orchestrator&.merchant_id

    # Check if we're winning - if so, no competitor to beat
    our_offers = offers.select { |o| o[:SellerId] == merchant_id }
    return nil if our_offers.any? { |o| o[:IsBuyBoxWinner] }

    # Find competitive offers (excluding us)
    competitive_offers = offers.reject { |o| o[:SellerId] == merchant_id }
    return nil if competitive_offers.empty?

    # First, try to find the buy box winner
    winning_offer = competitive_offers.find { |o| o[:IsBuyBoxWinner] }

    if winning_offer
      price = winning_offer.dig(:ListingPrice, :Amount) || winning_offer.dig(:Price, :Amount)
      return { price: price&.to_f, seller_id: winning_offer[:SellerId] }
    end

    # If no winner found, get the lowest priced competitor
    lowest_offer = competitive_offers.min_by do |offer|
      offer.dig(:ListingPrice, :Amount) || offer.dig(:Price, :Amount) || Float::INFINITY
    end

    return nil unless lowest_offer

    price = lowest_offer.dig(:ListingPrice, :Amount) || lowest_offer.dig(:Price, :Amount)
    { price: price&.to_f, seller_id: lowest_offer[:SellerId] }
  rescue JSON::ParserError, NoMethodError => e
    logger.error "Error parsing buy box status for catalog item #{catalog_item.id}: #{e.message}"
    nil
  end
end

#get_external_competitor_threshold(catalog_item) ⇒ Float?

Amazon's competitive benchmark for this listing, or nil.

Deliberately NOT the lower of CompetitivePriceThreshold and
SuggestedLowerPricePlusShipping — the suggestion is Amazon asking us to
charge less, so taking the min let it drive our price down and then block
the recovery. See Catalog::AmazonCompetitiveSignal.

Returns:

  • (Float, nil)


170
171
172
173
174
175
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 170

def get_external_competitor_threshold(catalog_item)
  Catalog::AmazonCompetitiveSignal.binding_threshold(buy_box_summary(catalog_item))
rescue JSON::ParserError, NoMethodError => e
  logger.error "Error parsing external competitor threshold for catalog item #{catalog_item.id}: #{e.message}"
  nil
end

#offers_ship_free?(catalog_item) ⇒ Boolean

Amazon's Summary thresholds (CompetitivePriceThreshold,
SuggestedLowerPricePlusShipping) are LANDED prices — listing price plus
shipping — while every price we store and feed is listing-only. The two are
directly comparable only while shipping is zero.

It always has been: 1,884 offer entries across 5 days of buy-box pulls on
2026-08-01 carried Shipping 0.00, with LandedPrice == ListingPrice in every
one. The scalar thresholds carry no shipping breakdown of their own, so
there is nothing to subtract even in principle — the only safe response to
a nonzero-shipping offer is to decline rather than misprice by that amount.

Returns:

  • (Boolean)

    whether landed and listing prices are interchangeable here



152
153
154
155
156
157
158
159
160
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 152

def offers_ship_free?(catalog_item)
  summary = buy_box_summary(catalog_item)
  return true if summary.blank?

  offers = (summary[:BuyBoxPrices] || []) + (summary[:LowestPrices] || [])
  offers.none? { |o| o.dig(:Shipping, :Amount).to_f.positive? }
rescue JSON::ParserError, NoMethodError
  true
end

#probe_for_buy_box(catalog_item, our_price, min_allowed_price) ⇒ Object

Probe for Buy Box by gradually lowering price
When Amazon doesn't give us a target price, we lower by the threshold percentage
to try to win the Buy Box



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/catalog/amazon_price_lowering_service.rb', line 215

def probe_for_buy_box(catalog_item, our_price, min_allowed_price)
  # Calculate target price (lower by threshold percentage)
  reduction_amount = our_price * COMPETITIVE_PRICE_THRESHOLD_PERCENT
  target_price = (our_price - reduction_amount).round(2)

  # Don't go below minimum
  if target_price < min_allowed_price
    if our_price > min_allowed_price
      # We can still lower to minimum
      target_price = min_allowed_price
    else
      # Already at or below minimum, can't probe further
      logger.info "Catalog Item #{catalog_item.id}: Cannot probe - already at minimum price"
      return Result.new(action: :no_action, reason: :at_minimum_price, new_price: nil, competitive_price: nil, competitor_seller_id: nil)
    end
  end

  # Apply the price reduction
  new_price = apply_probing_price(catalog_item, target_price)

  logger.info "Catalog Item #{catalog_item.id}: Probing for Buy Box - lowered from $#{our_price} to $#{new_price} (#{(COMPETITIVE_PRICE_THRESHOLD_PERCENT * 100).round(1)}% reduction)"

  Result.new(action: :price_lowered, reason: :probing_for_buy_box, new_price: new_price, competitive_price: nil, competitor_seller_id: 'PROBING')
end

#process(catalog_item, actor: nil) ⇒ Object

Process a single catalog item and attempt to lower price to win Buy Box
Returns a Result with :action (:price_lowered, :flagged, :no_action) and details



35
36
37
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 35

def process(catalog_item, actor: nil)
  with_actor(actor) { process_unguarded(catalog_item) }
end

#recently_lowered_price?(catalog_item) ⇒ Boolean

Check if price was lowered recently (within cooldown period)

Returns:

  • (Boolean)


206
207
208
209
210
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 206

def recently_lowered_price?(catalog_item)
  return false if catalog_item.price_updated_at.blank?

  catalog_item.price_updated_at > PROBING_COOLDOWN_HOURS.hours.ago
end