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 Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #options, #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)


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

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)


181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 181

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



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
130
131
132
133
134
135
136
137
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 91

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 unless ecl&.data.present?

  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



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 142

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 unless ecl&.data.present?

  begin
    json_hash = JSON.parse(ecl.data).with_indifferent_access
    summary = json_hash.dig(:payload, :Summary)
    return nil unless summary.present?

    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



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 207

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



34
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
80
81
82
83
84
85
86
87
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 34

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

  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
  price_difference_percent = (price_difference / our_price * 100).abs if our_price.positive?

  # 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

  # Check if we can lower price within threshold and above minimum
  if price_difference_percent <= (COMPETITIVE_PRICE_THRESHOLD_PERCENT * 100) &&
     competitive_price >= min_allowed_price

    new_price = lower_price_to_beat_competitor(catalog_item, competitive_price)
    return Result.new(action: :price_lowered, reason: :beat_competitor, new_price: new_price, competitive_price: competitive_price, competitor_seller_id: competitor_seller_id)
  end

  # Can't automatically win - flag for manual review
  flag_cannot_win(catalog_item, competitive_price, competitor_seller_id, price_difference_percent, min_allowed_price)
  Result.new(action: :flagged, reason: :price_change_too_large_or_below_minimum, new_price: nil, competitive_price: competitive_price, competitor_seller_id: competitor_seller_id)
end

#recently_lowered_price?(catalog_item) ⇒ Boolean

Check if price was lowered recently (within cooldown period)

Returns:

  • (Boolean)


198
199
200
201
202
# File 'app/services/catalog/amazon_price_lowering_service.rb', line 198

def recently_lowered_price?(catalog_item)
  return false unless catalog_item.price_updated_at.present?

  catalog_item.price_updated_at > PROBING_COOLDOWN_HOURS.hours.ago
end