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)
- 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
Instance Method Summary collapse
-
#amazon_seller?(seller_id) ⇒ Boolean
Check if seller ID is Amazon (US or Canada).
-
#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) ⇒ Object
Get the external competitor threshold from Amazon's competitive pricing signals (CompetitivePriceThreshold and SuggestedLowerPricePlusShipping) Returns the lower of the two, or nil.
-
#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) ⇒ 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)
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:
- 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)
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.}" 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.}" 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)
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 |