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 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, #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)
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:
- 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)
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.}" 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.}" 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)
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 |