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)
  • AmazonBuyBoxRecoveryService (hourly recovery for non-winners)

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)


162
163
164
165
166
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 162

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

  AMAZON_SELLER_IDS.include?(seller_id)
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)


173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 173

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_minimum_seller_allowed_price_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



83
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 83

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

Get the external competitor threshold from Amazon's competitive pricing signals
(CompetitivePriceThreshold and SuggestedLowerPricePlusShipping)
Returns the lower of the two, or nil



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
159
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 134

def get_external_competitor_threshold(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
    summary = json_hash.dig(:payload, :Summary)
    return nil if summary.blank?

    competitive_threshold = summary.dig(:CompetitivePriceThreshold, :Amount)&.to_f
    suggested_lower = summary.dig(:SuggestedLowerPricePlusShipping, :Amount)&.to_f

    thresholds = [competitive_threshold, suggested_lower].compact
    return nil if thresholds.empty?

    thresholds.min
  rescue JSON::ParserError, NoMethodError => e
    logger.error "Error parsing external competitor threshold for catalog item #{catalog_item.id}: #{e.message}"
    nil
  end
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



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 199

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) ⇒ 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
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
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 35

def process(catalog_item)
  return Result.new(action: :no_action, reason: :repricing_disabled, new_price: nil, competitive_price: nil, competitor_seller_id: nil) if catalog_item.disable_amz_repricing

  our_price = catalog_item.amazon_price_with_tax
  min_allowed_price = catalog_item.amazon_minimum_seller_allowed_price_with_tax

  # First check external competitor threshold (Amazon's competitive pricing signals)
  external_threshold = get_external_competitor_threshold(catalog_item)
  return handle_external_competitor(catalog_item, external_threshold, our_price, min_allowed_price) if external_threshold.present? && external_threshold < our_price

  # Then check seller-based competitors from the Offers array
  competitive_data = get_competitive_price(catalog_item)

  # If no competitive data, check if we should probe for Buy Box
  # This handles cases where Amazon says our price is too high but doesn't give us a target
  unless competitive_data
    return probe_for_buy_box(catalog_item, our_price, min_allowed_price) if eligible_for_buy_box_probing?(catalog_item)

    return Result.new(action: :no_action, reason: :no_competitive_data, new_price: nil, competitive_price: nil, competitor_seller_id: nil)
  end

  competitive_price = competitive_data[:price]
  competitor_seller_id = competitive_data[:seller_id]
  return Result.new(action: :no_action, reason: :no_competitive_price, new_price: nil, competitive_price: nil, competitor_seller_id: nil) unless competitive_price

  # Skip if competitor is Amazon - we don't compete with Amazon on price
  if amazon_seller?(competitor_seller_id)
    logger.info "Catalog Item #{catalog_item.id}: Competitor is Amazon (#{competitor_seller_id}) - skipping price competition"
    return Result.new(action: :no_action, reason: :competitor_is_amazon, new_price: nil, competitive_price: competitive_price, competitor_seller_id: competitor_seller_id)
  end

  price_difference = competitive_price - our_price

  # If we're already beating the competitor's price, no action needed
  if price_difference.positive?
    logger.info "Catalog Item #{catalog_item.id}: Our price (#{our_price}) already beats competitor (#{competitive_price})"
    return Result.new(action: :no_action, reason: :already_beating_competitor, new_price: nil, competitive_price: competitive_price, competitor_seller_id: competitor_seller_id)
  end

  # Step our price down toward the competitor (matching the external/seller
  # logic): match/undercut within one step, otherwise walk down gradually,
  # and flag only when the target is below our floor.
  compete_toward_target(catalog_item, competitive_price, our_price, min_allowed_price,
                        is_external: false, competitor_seller_id: competitor_seller_id)
end

#recently_lowered_price?(catalog_item) ⇒ Boolean

Check if price was lowered recently (within cooldown period)

Returns:

  • (Boolean)


190
191
192
193
194
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 190

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