Class: Catalog::AmazonPriceLoweringService
- Inherits:
-
BaseService
- Object
- BaseService
- Catalog::AmazonPriceLoweringService
- 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
Instance Method Summary collapse
-
#amazon_seller?(seller_id) ⇒ Boolean
Check if seller ID is Amazon (US or Canada).
-
#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.
-
#eligible_for_buy_box_probing?(catalog_item) ⇒ Boolean
Check if we're eligible for Buy Box probing Conditions: 1.
-
#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.
-
#get_external_competitor_threshold(catalog_item) ⇒ Float?
Amazon's competitive benchmark for this listing, or nil.
-
#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.
-
#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.
-
#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.
-
#recently_lowered_price?(catalog_item) ⇒ Boolean
Check if price was lowered recently (within cooldown period).
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)
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.
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:
- We're the Featured Merchant (eligible for Buy Box) but not winning
- We haven't lowered price recently (within PROBING_COOLDOWN_HOURS)
- We're above minimum price (have room to lower)
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.}" 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.
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.}" 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.
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)
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 |