Class: Catalog::AmazonBuyBoxService

Inherits:
BaseService show all
Defined in:
app/services/catalog/amazon_buy_box_service.rb

Overview

The single decision authority for one Amazon listing's Buy Box.

A listing is only ever in one of two situations, and each has exactly one
response:

Lost the Buy Box → corrective action, immediately. An ANY_OFFER_CHANGED
SQS notification tells us within seconds; #correct works the signal ladder
below down to a price that can win it back.

Holding the Buy Box → maintenance, once a day. #maintain creeps the
price toward its target while the raising service's stability and ceiling
gates allow.

Callers name the situation rather than passing a trigger. The notification
knows it is reporting a loss and calls #correct; the daily pass uses
#call, which routes by the listing's own Buy Box status. Nothing takes a
"which caller am I" flag, so the two responses cannot drift apart.

Before this, the rules were spread across the nightly service, an hourly
recovery sweep, and an event handler that could only revert a probationary
raise — so a Buy Box lost at 00:36 CT sat untouched until 06:15.

Mechanics (stepping, undercutting, floors, flagging) stay in
AmazonPriceLoweringService and AmazonPriceRaisingService.
This service decides whether to act and what price to aim at; those decide
how to get there.

Corrective signal ladder — first signal that yields a target wins:

  1. Amazon's own competitive benchmark (CompetitivePriceThreshold). Amazon
    telling us what it considers competitive is the strongest source of
    truth we have. Its SuggestedLowerPricePlusShipping is NOT part of this
    rung — that field asks us to charge less by construction, so honouring
    it walked prices down and then blocked the recovery
    (AmazonCompetitiveSignal).
  2. A live competing seller offer on the ASIN (Amazon's own seller IDs
    excluded — we don't race Vendor Central down).
  3. An external retailer in the same market with a verified-fresh lower
    price (see RETAILER_FRESHNESS). Reference retailers bind when set.
  4. Nothing to go on — hand to the lowering service's Buy Box probe.

Every target is still bounded by the floor
(Models::CatalogItemAmazonHelper#amazon_minimum_seller_allowed_price_with_tax, which carries
MAP), the per-item rule direction, and the step/cooldown limits.

Defined Under Namespace

Classes: Decision, Target

Constant Summary collapse

RETAILER_FRESHNESS =

An external retailer price older than this is not evidence of anything.
Borrowed from the refresher rather than restated, so the two freshness
gates cannot drift apart.

Retailer::SiblingPriceRefresher::FRESHNESS_THRESHOLD
LOWERING_COOLDOWN =

Minimum gap between two automated reductions on the same listing.
ANY_OFFER_CHANGED fires on every competitor move (~200/day across the
catalog), so without this an event-driven reaction would walk a price
down all afternoon on a single competitor's testing.

Catalog::AmazonPriceLoweringService::PROBING_COOLDOWN_HOURS.hours
CORRECTIVE_ACTOR =

PaperTrail actor for corrective price changes, so a Buy-Box rescue is
distinguishable from the daily maintenance creep in an item's history.

'Catalog::AmazonBuyBoxService (corrective)'
FAIR_PRICING_ACTOR =

A fair-pricing step down is neither a rescue nor a creep — it happens while
we are winning. Its own actor so "why did this price fall while we held the
Buy Box" is answerable from the item's history alone.

'Catalog::AmazonBuyBoxService (fair pricing)'

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

#initialize(lowering_service: nil, raising_service: nil) ⇒ AmazonBuyBoxService

Returns a new instance of AmazonBuyBoxService.



84
85
86
87
88
# File 'app/services/catalog/amazon_buy_box_service.rb', line 84

def initialize(lowering_service: nil, raising_service: nil)
  super()
  @lowering_service = lowering_service || Catalog::AmazonPriceLoweringService.new
  @raising_service = raising_service || Catalog::AmazonPriceRaisingService.new
end

Instance Method Details

#call(catalog_item) ⇒ Decision

The daily pass: maintain what is winning, correct what is not.

Only the scheduled run should use this. An event knows which situation it
is reporting and must say so by calling #correct directly — routing a
loss through here would let a stale won: false event (or an up_only
item, which ignores the Buy Box) come out the maintenance side and raise
a price in response to a loss.

Parameters:

Returns:



100
101
102
103
104
105
106
# File 'app/services/catalog/amazon_buy_box_service.rb', line 100

def call(catalog_item)
  # up_only climbs regardless of the Buy Box — a loss is not its concern, so
  # on the daily pass it maintains either way.
  return maintain(catalog_item) if catalog_item.is_amz_buy_box_winner || catalog_item.reprice_rule_up_only?

  correct(catalog_item)
end

#correct(catalog_item) ⇒ Decision

Corrective. The Buy Box is lost, so work the signal ladder down to a price
that can win it back. Meant to run the moment we hear about the loss, and
safe to call on a stale loss event: a rule that cannot lower simply
declines, and nothing here can raise a price.

Parameters:

Returns:



137
138
139
140
141
142
# File 'app/services/catalog/amazon_buy_box_service.rb', line 137

def correct(catalog_item)
  gate = common_gate(catalog_item)
  return Decision.new(action: :no_action, reason: gate) if gate

  correct_unguarded(catalog_item)
end

#maintain(catalog_item) ⇒ Decision

Maintenance. We hold the Buy Box, so the price either has to come down to
Amazon's fair-pricing threshold or creep toward its target — gated by the
rule and the raising service's own stability and ceiling checks. Slow by
design; a daily pass is the natural cadence.

Parameters:

Returns:



115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'app/services/catalog/amazon_buy_box_service.rb', line 115

def maintain(catalog_item)
  gate = common_gate(catalog_item)
  return Decision.new(action: :no_action, reason: gate) if gate

  correction = fair_pricing_correction(catalog_item)
  return correction if correction

  return Decision.new(action: :no_action, reason: :raising_disabled_by_rule) unless catalog_item.reprice_allows_raising?

  result = @raising_service.process(catalog_item)
  action = result.action == :price_increased ? :price_raised : :no_action
  Decision.new(action: action, reason: result.reason, price: result.try(:new_price),
               source: :maintenance, detail: result)
end