Module: Models::CatalogItemAmazonHelper

Extended by:
ActiveSupport::Concern
Includes:
ActionView::Helpers::UrlHelper, Memery, RouteUrlHelpers
Included in:
CatalogItem
Defined in:
app/concerns/models/catalog_item_amazon_helper.rb

Overview

ActiveSupport::Concern mixin: catalog item amazon helper.

Constant Summary collapse

AMAZON_DEFAULT_BUSINESS_DISCOUNT =

%

5
AMAZON_MIN_PROFIT_MARGIN =

Amazon min profit margin.

40
AMAZON_MARKETPLACE_LISTING_LOCALES =

Marketplaces that accept FEWER listing languages than their country
speaks. Keyed by marketplace identifier; absent means "trust the catalog".

Belgium is trilingual and catalog 157 lists :fr, :nl and :de, but
Amazon.be rejects a de_BE value on listing attributes with error 90244
("Nous ne pouvons pas accepter la valeur de_BE ... pour item_name").
Verified 2026-08-07: the same PATCH is ACCEPTED with issues [] once de is
dropped. This only began to bite when #1864 started submitting EVERY
catalog locale instead of just the first, which made all 20 Belgian
item_name PATCHes fail.
Values are frozen too: Hash#freeze does not freeze the nested arrays, and
this allowlist decides what we send outbound for every seller.

{
  'AMEN7PMS3EDWL' => %i[fr nl].freeze,   # Amazon.be — 90244 on a de_BE value
  'A2EUQ1WTGCTBG2' => %i[en-CA].freeze   # Amazon.ca — see below
}.freeze
AMAZON_LISTING_LANGUAGES =

Every language Amazon listing content may be written in across our
marketplaces. Intersected with a catalog's own locales by both
#amazon_locales and #amazon_gated_locales, which must agree on the
universe or the second would report a language as gated that we never
submit anywhere.

%i[en-US en-CA fr de es nl it pl pt sv].freeze

Constants included from RouteUrlHelpers

RouteUrlHelpers::ROUTES

Instance Attribute Summary collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#amazon_fnskuString?

Returns the Amazon Fulfillment Network SKU.

Returns:

  • (String, nil)

    the Amazon Fulfillment Network SKU



48
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 48

validates :amazon_fnsku, amazon_fnsku: true, uniqueness: true, if: :amazon_fba_labeling_fnsku?

Class Method Details

.amazonsActiveRecord::Relation<Models::CatalogItemAmazonHelper>

A relation of Models::CatalogItemAmazonHelpers that are amazons. Active Record Scope

Returns:

See Also:



67
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 67

scope :amazons, -> { where(catalog_id: CatalogConstants::AMAZON_CATALOG_IDS) }

.amazons_sellersActiveRecord::Relation<Models::CatalogItemAmazonHelper>

A relation of Models::CatalogItemAmazonHelpers that are amazons sellers. Active Record Scope

Returns:

See Also:



68
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 68

scope :amazons_sellers, -> { where(catalog_id: CatalogConstants::AMAZON_SELLER_IDS) }

.amazons_sellers_with_asinsActiveRecord::Relation<Models::CatalogItemAmazonHelper>

A relation of Models::CatalogItemAmazonHelpers that are amazons sellers with asins. Active Record Scope

Returns:

See Also:



70
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 70

scope :amazons_sellers_with_asins, -> { amazons.with_asin }

.amazons_with_asinsActiveRecord::Relation<Models::CatalogItemAmazonHelper>

A relation of Models::CatalogItemAmazonHelpers that are amazons with asins. Active Record Scope

Returns:

See Also:



69
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 69

scope :amazons_with_asins, -> { amazons.with_asin }

.by_asinsActiveRecord::Relation<Models::CatalogItemAmazonHelper>

A relation of Models::CatalogItemAmazonHelpers that are by asins. Active Record Scope

Returns:

See Also:



66
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 66

scope :by_asins, ->(*asins) { amazons_with_asins.where(items: { amazon_asin: [asins].flatten.uniq.compact }) }

.with_asinActiveRecord::Relation<Models::CatalogItemAmazonHelper>

A relation of Models::CatalogItemAmazonHelpers that are with asin. Active Record Scope

Returns:

See Also:



71
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 71

scope :with_asin, -> { amazons.joins(store_item: :item).where.not(items: { amazon_asin: [nil, ''] }) }

Instance Method Details

#amalytix_tagsArray<String>

Tags to send to Amalytix for this item.

Returns:

  • (Array<String>)


1003
1004
1005
1006
1007
1008
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 1003

def amalytix_tags
  tags = []
  tags += item.primary_product_line.self_and_ancestors.select { |pl| pl.available_to_public || pl.show_in_sales_portal }.first(3).map { |pl| pl.slug_ltree.to_s }
  tags << variant_group.sku if variant_group.present?
  tags
end

#amazon_asinString?

If catalog item represents an amazon asin we return that value

Returns:

  • (String, nil)


697
698
699
700
701
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 697

def amazon_asin
  return unless amazon_catalog_item?

  third_party_part_number.presence || item.amazon_asin
end

#amazon_business_priceBigDecimal, Float

The Amazon business price for this item before tax and FBA discount.

Returns:

  • (BigDecimal, Float)


765
766
767
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 765

def amazon_business_price
  (amount.to_f * amazon_business_price_factor).round(2)
end

#amazon_business_price_factorFloat

Returns a ratio to apply to Amazon Catalog price to get the business price

Returns:

  • (Float)


759
760
761
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 759

def amazon_business_price_factor
  (100.0 - amazon_effective_business_price_discount.to_f) / 100.0
end

#amazon_business_price_fbaBigDecimal, ...

The Amazon business price with the FBA discount applied.

Returns:

  • (BigDecimal, Float, nil)


771
772
773
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 771

def amazon_business_price_fba
  amazon_business_price&.-fba_discount
end

#amazon_business_price_with_taxBigDecimal, ...

Returns the computer amazon business price

Returns:

  • (BigDecimal, Float, nil)


777
778
779
780
781
782
783
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 777

def amazon_business_price_with_tax
  price = amazon_business_price

  return price unless tax_rate

  ((tax_rate + 1) * (price || 0.0)).round(2)
end

#amazon_business_price_with_tax_fbaBigDecimal, ...

The tax-inclusive Amazon business price with the FBA discount applied.

Returns:

  • (BigDecimal, Float, nil)


787
788
789
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 787

def amazon_business_price_with_tax_fba
  amazon_business_price_with_tax&.-fba_discount
end

#amazon_catalog_item?Boolean

Whether this item belongs to an Amazon catalog.

Returns:

  • (Boolean)


168
169
170
171
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 168

def amazon_catalog_item?
  # nil-safe: validations may run before the catalog association is set.
  catalog&.amazon_catalog?
end

#amazon_current_imagesHash{String => String}

Extracts the current image URLs from Amazon retailer information.

Returns:

  • (Hash{String => String})

    image slot (e.g. "MAIN", "PT01") => URL



667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 667

def amazon_current_images
  # This differs if you're vendor or seller
  current_images = {}
  if amazon_vendor_item?
    current_images['MAIN'] = retailer_information_first_locale.dig(:catalog, :payload, :summaries, 0, :mainImage, :link)
    (1..9).to_a.each do |i|
      # Note that vendor does not give us the image type use, just a positional number so we guess and assign it PT0x
      media_url = retailer_information_first_locale.dig('attributes', "other_product_image_locator_#{i}", 0, 'media_location')
      current_images["PT0#{i}"] = media_url if media_url.present?
    end
  elsif amazon_seller_item?

    images_in_marketplace = retailer_information_first_locale.dig(:catalog, :payload, :images, 0, :images) || []
    largest_images = {}
    # On seller, it returns different sizes of the same image, 1600, 500, 75
    images_in_marketplace.each do |image|
      variant = image['variant']

      # If we haven't seen this variant yet, or if the current image is larger, update the hash
      largest_images[variant] = image if !largest_images[variant] || (image['width'] * image['height'] > largest_images[variant]['width'] * largest_images[variant]['height'])
    end

    # Get the resulting images as an array
    current_images = largest_images.transform_values { |value| value[:link] }
  end
  current_images
end

#amazon_delete_information(attribute_actions: nil) ⇒ Hash{Symbol => Symbol, String, Array<EdiCommunicationLog>}

Deletes Amazon listing information for this catalog item.

Parameters:

  • attribute_actions (Hash, nil) (defaults to: nil)

    per-attribute action overrides

Returns:



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 379

def amazon_delete_information(attribute_actions: nil)
  return { status: :skipped, message: 'Not an amazon item' } unless amazon_catalog_item?
  return { status: :skipped, message: 'Orchestrator could not be loaded' } unless (orchestrator = catalog.load_orchestrator).respond_to?(:delete_listing_from_catalog_item)

  res = orchestrator.delete_listing_from_catalog_item(catalog_item: self, http_method: 'DELETE', attribute_actions:)
  result = if res.any?(false)
             { status: :error, message: "Catalog Item ASIN #{amazon_asin}: could not delete listing from Amazon! ASIN not found?" }
           elsif has_amazon_fba?
             res = orchestrator.delete_listing_from_catalog_item(catalog_item: self, http_method: 'DELETE', attribute_actions:, use_fba_sku: true)
             if res.any?(false)
               status = :error if res.all?(false)
               status = :partial_error if res.any? { |r| r == false || r.exception? } && res.any?(&:processed?)
               { status: status, message: "Catalog Item ASIN #{amazon_asin}: listing deleted from Amazon! but FBA SKU #{amazon_fba_sku}: listing could not be deleted from Amazon! ASIN not found?", ecls: res.reject { |r| r == false } }
             else
               amazon_delete_result(res, "Catalog Item ASIN #{amazon_asin} and FBA SKU #{amazon_fba_sku}: listings deleted from Amazon!")
             end
           else
             amazon_delete_result(res, "Catalog Item SKU #{sku}: listing deleted from Amazon!")
           end

  # After a successful DELETE, transition pending_discontinue -> discontinued
  discontinue! if result[:status] == :success && pending_discontinue? && can_discontinue?

  result
end

#amazon_descriptionObject

Alias for
to: :item#amazon_description

Returns:

  • (Object)
             to: :item#amazon_description
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_effective_business_price_discountInteger

What is the effective business discount for amazon

Returns:

  • (Integer)


753
754
755
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 753

def amazon_effective_business_price_discount
  business_discount_override.presence || AMAZON_DEFAULT_BUSINESS_DISCOUNT
end

#amazon_effective_desired_product_typeString?

The catalog classification we want the ASIN to carry (e.g. TOWEL_WARMER).
Read-only on Amazon's side; used as the yardstick for misclassification
(see #amazon_product_type_incoherent?) — NOT what the listing feed submits.

Returns:

  • (String, nil)


515
516
517
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 515

def amazon_effective_desired_product_type
  amazon_desired_product_type.presence || variant_group&.amazon_desired_product_type
end

#amazon_effective_product_schemaString?

Submittable Listings product type (e.g. TOWEL_HOLDER), falling back to the
variation's when the catalog item itself has none — mirrors
#amazon_effective_desired_product_type.

Returns:

  • (String, nil)


507
508
509
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 507

def amazon_effective_product_schema
  amazon_product_schema.presence || variant_group&.amazon_product_schema
end

#amazon_feature_1Object

Alias for
to: :item#amazon_feature_1

Returns:

  • (Object)
             to: :item#amazon_feature_1
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_2Object

Alias for
to: :item#amazon_feature_2

Returns:

  • (Object)
             to: :item#amazon_feature_2
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_3Object

Alias for
to: :item#amazon_feature_3

Returns:

  • (Object)
             to: :item#amazon_feature_3
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_4Object

Alias for
to: :item#amazon_feature_4

Returns:

  • (Object)
             to: :item#amazon_feature_4
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_5Object

Alias for
to: :item#amazon_feature_5

Returns:

  • (Object)
             to: :item#amazon_feature_5
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_6Object

Alias for
to: :item#amazon_feature_6

Returns:

  • (Object)
             to: :item#amazon_feature_6
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_7Object

Alias for
to: :item#amazon_feature_7

Returns:

  • (Object)
             to: :item#amazon_feature_7
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_8Object

Alias for
to: :item#amazon_feature_8

Returns:

  • (Object)
             to: :item#amazon_feature_8
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_feature_9Object

Alias for
to: :item#amazon_feature_9

Returns:

  • (Object)
             to: :item#amazon_feature_9
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_gated_localesArray<Symbol>

The complement of #amazon_locales: languages this catalog carries that its
marketplace REFUSES on listing attributes.

These are not hypothetical. Values in them are sitting on live listings —
written before the gate landed, or machine-translated onto the listing by
Amazon itself — and Amazon keeps flagging them (100720 on Amazon.ca for
every attribute still holding an fr_CA entry). Because a PATCH is
selector-scoped, re-pushing en_CA overwrites only the English entry and
leaves the French one untouched, so no amount of resubmission clears it.
Removal takes an explicit op: delete carrying the language_tag selector,
which is what Edi::Amazon::JsonListingGenerator::BaseGenerator#get_patches
emits from this list.

Returns:

  • (Array<Symbol>)


458
459
460
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 458

def amazon_gated_locales
  (catalog.locales & AMAZON_LISTING_LANGUAGES) - amazon_locales
end

#amazon_generic_keywordObject

Alias for
to: :item#amazon_generic_keyword

Returns:

  • (Object)
             to: :item#amazon_generic_keyword
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_item_costBigDecimal, Float

This can be refined later to add referral fee and shipping cogs

Returns:

  • (BigDecimal, Float)


966
967
968
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 966

def amazon_item_cost
  store_item.unit_cogs.round(4)
end

#amazon_json_generator(marketplace_id: nil, attribute_actions: nil, fba: false, language_tag: nil, business_price_available: true) ⇒ Edi::Amazon::JsonListingGenerator::BaseGenerator

Note:

:reek:UnusedParameters

Builds the JSON listing generator for this catalog item.

marketplace_id is accepted and ignored on purpose: callers reach this
through catalog_item_or_variation.amazon_json_generator(marketplace_id:)
(Edi::Amazon::ListingMessageProcessor), and VariantGroup#amazon_json_generator
REQUIRES it. A catalog item already knows its marketplace via its catalog, so
dropping the keyword here would break the shared call site.

Parameters:

  • marketplace_id (String, nil) (defaults to: nil)

    accepted and ignored for call-site compatibility

  • attribute_actions (Hash, nil) (defaults to: nil)

    per-attribute action overrides

  • fba (Boolean) (defaults to: false)

    whether the listing targets FBA

  • language_tag (String, nil) (defaults to: nil)

    BCP-47 language tag override

  • business_price_available (Boolean) (defaults to: true)

    whether business pricing is enabled

Returns:



534
535
536
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 534

def amazon_json_generator(marketplace_id: nil, attribute_actions: nil, fba: false, language_tag: nil, business_price_available: true) # rubocop:disable Lint/UnusedMethodArgument
  Edi::Amazon::JsonListingGenerator::Factory.generator_for_catalog_item(self, attribute_actions:, fba:, language_tag:, business_price_available:)
end

#amazon_label_requirementsArray<String>

Lists missing fields that prevent printing Amazon FBA labels.

Returns:

  • (Array<String>)


738
739
740
741
742
743
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 738

def amazon_label_requirements
  errors = []
  errors << 'ASIN number missing' if third_party_part_number.blank?
  errors << 'UPC missing' if item.upc.blank?
  errors
end

#amazon_list_priceBigDecimal, ...

The list price (MSRP) for Amazon, preferring the tax-inclusive value.

Returns:

  • (BigDecimal, Float, nil)


909
910
911
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 909

def amazon_list_price
  msrp_with_vat || msrp
end

#amazon_localesArray<Symbol>

Locales we may submit listing content in for this catalog's marketplace.

The catalog's locales are the languages the COUNTRY speaks; a marketplace
can accept fewer. Submitting one it does not accept fails the whole PATCH
with 90244, so the marketplace has the final say where we know it differs.

Canada is the reason this is about marketplace CAPABILITY and not about our
content. Amazon.ca answers 100720 ("Invalid language data") for every
attribute carrying an fr_CA entry — measured on a live listing: 20
attributes held fr_CA, all 20 were flagged, item_name included. A full
French translation does not help, so this is not a coverage gap we can fill
from our side. French copy stays in Mobility for the website and for the
day Amazon.ca accepts it; we just do not put it on the wire.

Returns:

  • (Array<Symbol>)


438
439
440
441
442
443
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 438

def amazon_locales
  # Include supported marketplace languages; keep two-letter language tags to match catalog.locales
  locales = catalog.locales & AMAZON_LISTING_LANGUAGES
  supported = AMAZON_MARKETPLACE_LISTING_LOCALES[catalog.amazon_marketplace&.marketplace_identifier]
  supported ? locales & supported : locales
end

#amazon_lowest_quantity_discount_priceBigDecimal, ...

The lowest active quantity-discount price for Amazon.

Returns:

  • (BigDecimal, Float, nil)


793
794
795
796
797
798
799
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 793

def amazon_lowest_quantity_discount_price
  (1..5).to_a.reverse_each do |threshold_index|
    discounted_price = amazon_quantity_discount_price(threshold_index)
    return discounted_price if discounted_price.present?
  end
  nil
end

#amazon_lowest_quantity_discounted_pricenil

Placeholder for the lowest quantity-discounted price calculation.

Returns:

  • (nil)


816
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 816

def amazon_lowest_quantity_discounted_price; end

#amazon_maximum_seller_allowed_price_with_taxBigDecimal, Float

The maximum price Amazon is allowed to offer for this item, with tax.

Returns:

  • (BigDecimal, Float)


826
827
828
829
830
831
832
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 826

def amazon_maximum_seller_allowed_price_with_tax
  price = [msrp, amount].max

  return price unless tax_rate

  ((tax_rate + 1) * (price || 0.0)).round(2)
end

#amazon_maximum_seller_allowed_price_with_tax_fbaBigDecimal, ...

The maximum allowed price with tax and the FBA discount applied.

Returns:

  • (BigDecimal, Float, nil)


836
837
838
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 836

def amazon_maximum_seller_allowed_price_with_tax_fba
  amazon_maximum_seller_allowed_price_with_tax&.-fba_discount
end

#amazon_minimum_profit_marginInteger

The configured minimum profit margin percentage for Amazon.

Returns:

  • (Integer)


820
821
822
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 820

def amazon_minimum_profit_margin
  AMAZON_MIN_PROFIT_MARGIN
end

#amazon_minimum_seller_allowed_price_with_taxBigDecimal, Float

Creates the lower threshold for minimum price (in case of automated rule)
Take the smallest of

  • the price yielding AMAZON_MIN_PROFIT_MARGIN on unit_cogs
  • the lowest quantity-discount price
  • catalog item amount (our price)
  • Amazon business price
  • Effective price (in case a sale is in effect)
  • amz_min_seller_price_override (manual, lowers only — it is a .min
    candidate, not an authoritative floor)

This value ships to Amazon as minimum_seller_allowed_price, so it must stay
below EVERY price we actually offer — including the B2B price and the
quantity-discount price, which is why both are in the list. Declaring a
minimum above one of our own offers is Amazon error 18155 ("your selling
price is below your minimum price threshold") and suppresses the listing.
MAP deliberately does NOT appear here; see #amazon_repricing_floor_with_tax.

Returns:

  • (BigDecimal, Float)


857
858
859
860
861
862
863
864
865
866
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 857

def amazon_minimum_seller_allowed_price_with_tax
  min_margin_price = calculate_minimum_price_for_margin(amazon_minimum_profit_margin)
  msap = [min_margin_price, amazon_lowest_quantity_discount_price, amount, amazon_business_price, effective_price, amz_min_seller_price_override].compact.min
  # Whatever our msap is because of another random rule on amazon, shave it by one percent, amazon price upload requires it
  price = (msap * 0.99).round(2)

  return price unless tax_rate

  ((tax_rate + 1) * (price || 0.0)).round(2)
end

#amazon_minimum_seller_allowed_price_with_tax_fbaBigDecimal, ...

The minimum allowed price with tax and the FBA discount applied.

Returns:

  • (BigDecimal, Float, nil)


903
904
905
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 903

def amazon_minimum_seller_allowed_price_with_tax_fba
  amazon_minimum_seller_allowed_price_with_tax&.-fba_discount
end

#amazon_patch_information(attribute_actions: nil, use_fba_sku: false) ⇒ Hash{Symbol => Symbol, String, Array<EdiCommunicationLog>}

Patches Amazon listing attributes for this catalog item.

Parameters:

  • attribute_actions (Hash, nil) (defaults to: nil)

    per-attribute action overrides

  • use_fba_sku (Boolean) (defaults to: false)

    whether to target the FBA SKU

Returns:



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 320

def amazon_patch_information(attribute_actions: nil, use_fba_sku: false)
  return { status: :skipped, message: 'Not an amazon item' } unless amazon_catalog_item?
  return { status: :skipped, message: 'Orchestrator could not be loaded' } unless (orchestrator = catalog.load_orchestrator).respond_to?(:push_listing_from_catalog_item)
  return { status: :skipped, message: 'Not an FBA amazon catalog item' } if use_fba_sku && !has_amazon_fba?

  fbm_vs_fba_sku_snippet = (use_fba_sku ? "FBA SKU #{amazon_fba_sku}" : "SKU #{sku}")

  res = orchestrator.push_listing_from_catalog_item(self, http_method: 'PATCH', attribute_actions:, use_fba_sku:)

  # Separate error hashes from ECL objects
  error_results, ecl_results = res.partition { |r| r.is_a?(Hash) && r[:error] }

  if error_results.any?
    error_messages = error_results.pluck(:message).join('; ')
    status = ecl_results.any?(&:processed?) ? :partial_error : :error
    { status: status, message: "#{fbm_vs_fba_sku_snippet}: #{error_messages}", ecls: ecl_results }
  elsif ecl_results.all?(&:processed?)
    { status: :success, message: "Catalog Item #{fbm_vs_fba_sku_snippet}: information patched to Amazon!", ecls: ecl_results }
  elsif ecl_results.any?(&:exception?)
    status = ecl_results.any?(&:processed?) ? :partial_error : :error
    { status: status, message: "Catalog Item #{fbm_vs_fba_sku_snippet}: some operations failed", ecls: ecl_results }
  else
    { status: :error, message: "Catalog Item #{fbm_vs_fba_sku_snippet}: unknown state", ecls: ecl_results }
  end
end

#amazon_price_with_taxBigDecimal, Float

The regular Amazon price, preferring the tax-inclusive value.

Returns:

  • (BigDecimal, Float)


924
925
926
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 924

def amazon_price_with_tax
  price_with_vat || amount
end

#amazon_price_with_tax_fbaBigDecimal, ...

The regular Amazon price with tax and the FBA discount applied.

Returns:

  • (BigDecimal, Float, nil)


930
931
932
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 930

def amazon_price_with_tax_fba
  amazon_price_with_tax&.-fba_discount
end

#amazon_product_data(fba: false) ⇒ Hash

Generates the Amazon product data payload for this catalog item.

Parameters:

  • fba (Boolean) (defaults to: false)

    whether to use FBA logic

Returns:

  • (Hash)

    generated Amazon product data



420
421
422
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 420

def amazon_product_data(fba: false)
  amazon_json_generator(fba: fba).generate
end

#amazon_product_type_in_effectString?

The Amazon Listings product type actually submitted (patch/put productType)
and used to load the attribute schema.

Precedence submits what Amazon ALREADY HAS before what we want, because a
type Amazon has not launched for listings (e.g. TOWEL_WARMER) is rejected
with issue 4000003 — see SP-API Developer Support case #21004871501:

  1. explicit override (amazon_product_schema) — the escape hatch, needed to
    ONBOARD an item whose desired type is unlaunched (nothing reported yet),
  2. the type our live listing is registered under (listing pull),
  3. Amazon's catalog classification for the ASIN,
  4. our desired type — reached only during onboarding, when Amazon has
    reported nothing back yet.

amazon_desired_product_type is therefore the TARGET we appeal toward and the
yardstick for misclassification reporting (#amazon_product_type_incoherent?),
NOT the value we submit while Amazon still reports something else. When Amazon
flips the listing, the reported type changes and submissions follow on their own.

Returns:

  • (String, nil)


481
482
483
484
485
486
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 481

def amazon_product_type_in_effect
  amazon_effective_product_schema.presence ||
    amazon_listing_reported_product_type.presence ||
    amazon_reported_product_type.presence ||
    amazon_effective_desired_product_type.presence
end

#amazon_product_type_incoherent?Boolean

Whether Amazon's reported product type differs from our desired type.

Returns:

  • (Boolean)


659
660
661
662
663
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 659

def amazon_product_type_incoherent?
  return false unless amazon_reported_product_type && amazon_effective_desired_product_type

  amazon_reported_product_type != amazon_effective_desired_product_type
end

#amazon_product_urlString?

Builds the product detail URL on Amazon for this item's ASIN.

Returns:

  • (String, nil)


651
652
653
654
655
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 651

def amazon_product_url
  return unless amazon_marketplace && amazon_asin

  amazon_marketplace.url_for_asin(amazon_asin)
end

#amazon_pull_buy_box_statusHash{Symbol => Symbol, String}

Pulls the current Buy Box status from Amazon for this item.

Returns:

  • (Hash{Symbol => Symbol, String})

    status and message hash



209
210
211
212
213
214
215
216
217
218
219
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 209

def amazon_pull_buy_box_status
  return { status: :skipped, message: 'Not an amazon item or no asin present' } unless amazon_catalog_item? && amazon_asin.present?
  return { status: :skipped, message: 'Orchestrator could not be loaded' } unless (orchestrator = catalog.load_orchestrator).respond_to?(:pull_buy_box_status)

  res = orchestrator.pull_buy_box_status(self)
  if res.any? { |h| h.values.any?(false) }
    { status: :error, message: "Catalog Item ASIN #{amazon_asin}: could not pull buy box status from Amazon! ASIN not found?" }
  else
    { status: :success, message: "Catalog Item ASIN #{amazon_asin}: buy box status pulled from Amazon!" }
  end
end

#amazon_pull_catalog_informationHash{Symbol => Symbol, String}

Pulls catalog information from Amazon for this item's ASIN.

Returns:

  • (Hash{Symbol => Symbol, String})

    status and message hash



193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 193

def amazon_pull_catalog_information
  return { status: :skipped, message: 'Not an amazon item or no asin present' } unless amazon_catalog_item? && amazon_asin.present?
  # A Vendor Central catalog can resolve to the order-only Edi::MftGateway::Orchestrator,
  # which lacks these listing methods — respond_to? guards both nil and wrong-orchestrator (AppSignal #6328).
  return { status: :skipped, message: 'Orchestrator could not be loaded' } unless (orchestrator = catalog.load_orchestrator).respond_to?(:pull_catalog_information)

  res = orchestrator.pull_catalog_information(self)
  if res.any? { |h| h.values.any?(false) }
    { status: :error, message: "Catalog Item ASIN #{amazon_asin}: could not pull information from Amazon! ASIN not found?" }
  else
    { status: :success, message: "Catalog Item ASIN #{amazon_asin}: information pulled from Amazon!" }
  end
end

#amazon_pull_listing_informationHash{Symbol => Symbol, String}

Pulls the current listing information from Amazon for this item.

Returns:

  • (Hash{Symbol => Symbol, String})

    status and message hash



223
224
225
226
227
228
229
230
231
232
233
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 223

def amazon_pull_listing_information
  return { status: :skipped, message: 'Not an amazon item' } unless amazon_catalog_item?
  return { status: :skipped, message: 'Orchestrator could not be loaded' } unless (orchestrator = catalog.load_orchestrator).respond_to?(:pull_listing_information)

  res = orchestrator.pull_listing_information(self)
  if res.any? { |h| h.values.any?(false) }
    { status: :error, message: "Catalog Item ID #{id}: could not pull information from Amazon!" }
  else
    { status: :success, message: "Catalog Item ID #{id}: information pulled from Amazon!" }
  end
end

#amazon_pull_listing_schema(product_type = nil) ⇒ Hash{Symbol => Symbol, String}

Pulls the JSON attribute schema for an Amazon listing product type.

Parameters:

  • product_type (String, nil) (defaults to: nil)

    Amazon product type to load the schema for

Returns:

  • (Hash{Symbol => Symbol, String})

    status and message hash



238
239
240
241
242
243
244
245
246
247
248
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 238

def amazon_pull_listing_schema(product_type = nil)
  return { status: :skipped, message: 'Not an amazon item' } unless amazon_catalog_item?
  return { status: :skipped, message: 'Orchestrator could not be loaded' } unless (orchestrator = catalog.load_orchestrator).respond_to?(:pull_listing_schema)

  res = orchestrator.pull_listing_schema(self, product_type)
  if res.any? { |h| h.values.any?(false) }
    { status: :error, message: "Catalog Item ID #{id}: could not pull schema from Amazon!" }
  else
    { status: :success, message: "Catalog Item ID #{id}: schema pulled from Amazon!" }
  end
end

#amazon_put_information(attribute_actions: nil, use_fba_sku: false) ⇒ Hash{Symbol => Symbol, String, Array<EdiCommunicationLog>}

Puts (fully replaces) Amazon listing attributes for this catalog item.

Parameters:

  • attribute_actions (Hash, nil) (defaults to: nil)

    per-attribute action overrides

  • use_fba_sku (Boolean) (defaults to: false)

    whether to target the FBA SKU

Returns:



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 350

def amazon_put_information(attribute_actions: nil, use_fba_sku: false)
  return { status: :skipped, message: 'Not an amazon item' } unless amazon_catalog_item?
  return { status: :skipped, message: 'Orchestrator could not be loaded' } unless (orchestrator = catalog.load_orchestrator).respond_to?(:push_listing_from_catalog_item)
  return { status: :skipped, message: 'Not an FBA amazon catalog item' } if use_fba_sku && !has_amazon_fba?

  fbm_vs_fba_sku_snippet = (use_fba_sku ? "FBA SKU #{amazon_fba_sku}" : "SKU #{sku}")

  res = orchestrator.push_listing_from_catalog_item(self, http_method: 'PUT', attribute_actions:, use_fba_sku:)

  # Separate error hashes from ECL objects
  error_results, ecl_results = res.partition { |r| r.is_a?(Hash) && r[:error] }

  if error_results.any?
    error_messages = error_results.pluck(:message).join('; ')
    status = ecl_results.any?(&:processed?) ? :partial_error : :error
    { status: status, message: "#{fbm_vs_fba_sku_snippet}: #{error_messages}", ecls: ecl_results }
  elsif ecl_results.all?(&:processed?)
    { status: :success, message: "Catalog Item #{fbm_vs_fba_sku_snippet}: information put to Amazon!", ecls: ecl_results }
  elsif ecl_results.any?(&:exception?)
    status = ecl_results.any?(&:processed?) ? :partial_error : :error
    { status: status, message: "Catalog Item #{fbm_vs_fba_sku_snippet}: some operations failed", ecls: ecl_results }
  else
    { status: :error, message: "Catalog Item #{fbm_vs_fba_sku_snippet}: unknown state", ecls: ecl_results }
  end
end

#amazon_quantity_discount_price(threshold_index) ⇒ BigDecimal, ...

Calculates the quantity-discount price for a given threshold index.

Parameters:

  • threshold_index (Integer)

    the 1-based quantity discount tier

Returns:

  • (BigDecimal, Float, nil)


804
805
806
807
808
809
810
811
812
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 804

def amazon_quantity_discount_price(threshold_index)
  return unless (discount = send(:"quantity_#{threshold_index}_price_discount"))

  if quantity_discount_price_type_percent?
    (amount * ((100.0 - discount.to_f) / 100.0)).round(2)
  elsif quantity_discount_price_type_fixed?
    (amount - discount)
  end
end

#amazon_repricing_floor_with_taxBigDecimal, ...

The floor the REPRICER may not cross — the declared Amazon minimum, raised
to MAP where MAP is knowable.

Deliberately separate from #amazon_minimum_seller_allowed_price_with_tax.
That value is fed to Amazon and is bounded by our own offers; this one is
an internal guard and is bounded by nothing, so MAP can genuinely stop the
repricer from undercutting the price we enforce on resellers.

Merging the two (2026-08-01) declared minimums above our own B2B and
quantity-discount prices on 220 listings and had Amazon suppress them.

Returns:

  • (BigDecimal, Float, nil)


880
881
882
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 880

def amazon_repricing_floor_with_tax
  [amazon_minimum_seller_allowed_price_with_tax, map_price_with_tax].compact.max
end

#amazon_sale_price_with_taxBigDecimal, ...

The Amazon sale price, preferring the tax-inclusive value.

Returns:

  • (BigDecimal, Float, nil)


936
937
938
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 936

def amazon_sale_price_with_tax
  sale_price_with_vat || sale_price
end

#amazon_sale_price_with_tax_fbaBigDecimal, ...

The Amazon sale price with tax and the FBA discount applied.

Returns:

  • (BigDecimal, Float, nil)


942
943
944
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 942

def amazon_sale_price_with_tax_fba
  amazon_sale_price_with_tax&.-fba_discount
end

#amazon_schemaAmazonSchema?

Loads the cached AmazonSchema row for the effective product type and marketplace.

Returns:



490
491
492
493
494
495
496
497
498
499
500
501
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 490

def amazon_schema
  product_type = amazon_product_type_in_effect
  return if product_type.blank?

  AmazonSchema
    .amazon_channel_seller
    .where(
      amazon_marketplace: catalog&.amazon_marketplace&.id,
      product_type: product_type
    )
    .first
end

#amazon_seller_item?Boolean

Whether this item is sold through an Amazon Seller (SC) catalog.

Returns:

  • (Boolean)


181
182
183
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 181

def amazon_seller_item?
  catalog_id.in?(CatalogConstants::AMAZON_SC_CATALOG_IDS + CatalogConstants::AMAZON_EU_CATALOG_IDS)
end

#amazon_send_patch_listing_information(attribute_actions: nil, fbm_vs_fba: 'Both') ⇒ Hash{Symbol => Symbol, ActiveSupport::SafeBuffer, Array<EdiCommunicationLog>}

Sends a PATCH listing update to Amazon for FBM and/or FBA SKUs.

Parameters:

  • attribute_actions (Hash, nil) (defaults to: nil)

    per-attribute action overrides

  • fbm_vs_fba (String) (defaults to: 'Both')

    which fulfillment channels to update: "FBM", "FBA", or "Both"

Returns:

  • (Hash{Symbol => Symbol, ActiveSupport::SafeBuffer, Array<EdiCommunicationLog>})

    status, message, and ECLs



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 254

def amazon_send_patch_listing_information(attribute_actions: nil, fbm_vs_fba: 'Both')
  if api_ready_state?
    res_arr = []
    sku_labels = ['', '-FBA'] if %w[Both].include?(fbm_vs_fba)
    sku_labels = [''] if %w[FBM].include?(fbm_vs_fba)
    sku_labels = ['-FBA'] if %w[FBA].include?(fbm_vs_fba)
    res_arr << amazon_patch_information(attribute_actions:, use_fba_sku: false) if %w[Both FBM].include?(fbm_vs_fba)
    res_arr << amazon_patch_information(attribute_actions:, use_fba_sku: true) if %w[Both FBA].include?(fbm_vs_fba)
    status = :success if res_arr.all? { |r| r[:status] == :success }
    status = :partial_skipped if res_arr.any? { |r| r[:status] == :skipped }
    status = :skipped if res_arr.all? { |r| r[:status] == :skipped }
    status = :partial_error if res_arr.any? { |r| r[:status] == :error }
    status = :error if res_arr.all? { |r| r[:status] == :error }

    ecl_links = res_arr.map.with_index do |r, i|
      Array(r[:ecls]).map do |ecl|
        link_to("#{sku}#{sku_labels[i]} (#{ecl.state})", edi_communication_log_path(ecl), target: '_blank', rel: 'noopener noreferrer')
      end
    end.flatten.compact

    message_parts = res_arr.pluck(:message).join(' ')
    message_suffix = ecl_links.any? ? " See: #{ecl_links.join(', ')}" : ''

    { status: status, message: "#{message_parts}#{message_suffix}".html_safe }
  else
    { status: :skipped, message: 'Not an active or pending onboarding item' }
  end
end

#amazon_send_put_listing_information(attribute_actions: nil, fbm_vs_fba: 'Both') ⇒ Hash{Symbol => Symbol, ActiveSupport::SafeBuffer, Array<EdiCommunicationLog>}

Sends a PUT listing update to Amazon for FBM and/or FBA SKUs.

Parameters:

  • attribute_actions (Hash, nil) (defaults to: nil)

    per-attribute action overrides

  • fbm_vs_fba (String) (defaults to: 'Both')

    which fulfillment channels to update: "FBM", "FBA", or "Both"

Returns:

  • (Hash{Symbol => Symbol, ActiveSupport::SafeBuffer, Array<EdiCommunicationLog>})

    status, message, and ECLs



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 287

def amazon_send_put_listing_information(attribute_actions: nil, fbm_vs_fba: 'Both')
  if api_ready_state?
    res_arr = []
    sku_labels = ['', '-FBA'] if %w[Both].include?(fbm_vs_fba)
    sku_labels = [''] if %w[FBM].include?(fbm_vs_fba)
    sku_labels = ['-FBA'] if %w[FBA].include?(fbm_vs_fba)
    res_arr << amazon_put_information(attribute_actions:, use_fba_sku: false) if %w[Both FBM].include?(fbm_vs_fba)
    res_arr << amazon_put_information(attribute_actions:, use_fba_sku: true) if %w[Both FBA].include?(fbm_vs_fba)
    status = :success if res_arr.all? { |r| r[:status] == :success }
    status = :partial_skipped if res_arr.any? { |r| r[:status] == :skipped }
    status = :skipped if res_arr.all? { |r| r[:status] == :skipped }
    status = :partial_error if res_arr.any? { |r| r[:status] == :error }
    status = :error if res_arr.all? { |r| r[:status] == :error }

    ecl_links = res_arr.map.with_index do |r, i|
      Array(r[:ecls]).map do |ecl|
        link_to("#{sku}#{sku_labels[i]} (#{ecl.state})", edi_communication_log_path(ecl), target: '_blank', rel: 'noopener noreferrer')
      end
    end.flatten.compact

    message_parts = res_arr.pluck(:message).join(' ')
    message_suffix = ecl_links.any? ? " See: #{ecl_links.join(', ')}" : ''

    { status: status, message: "#{message_parts}#{message_suffix}".html_safe }
  else
    { status: :skipped, message: 'Not an active or pending onboarding item' }
  end
end

#amazon_target_keywordsObject

Alias for
to: :item#amazon_target_keywords

Returns:

  • (Object)
             to: :item#amazon_target_keywords
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_titleObject

Alias for
to: :item#amazon_title

Returns:

  • (Object)
             to: :item#amazon_title
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item

#amazon_vendor_codeString?

The vendor code for this item's catalog, if any.

Returns:

  • (String, nil)


413
414
415
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 413

def amazon_vendor_code
  catalog.vendor_code.presence
end

#amazon_vendor_item?Boolean

Whether this item is sold through an Amazon Vendor Central catalog.

Returns:

  • (Boolean)


187
188
189
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 187

def amazon_vendor_item?
  catalog_id.in?(CatalogConstants::AMAZON_VC_CATALOG_IDS)
end

#amz_available_attributesArray<String>

List all available Amazon attribute names that can be used with get_amz_attribute

Returns:

  • (Array<String>)

    Sorted list of attribute names



626
627
628
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 626

def amz_available_attributes
  Edi::Amazon::JsonListingGenerator::Attributes::AttributeFactory.attributes_available
end

#api_ready_state?Boolean

Whether the item is in a state that can receive Amazon API pushes/pulls.

Returns:

  • (Boolean)


175
176
177
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 175

def api_ready_state?
  active? || active_hidden? || pending_onboarding? || require_vendor_update? || pending_vendor_update?
end

#broadcast_amazon_dashboard_update(skip_amazon_check: false) ⇒ void

This method returns an undefined value.

Broadcast updates to the Amazon Catalog Items dashboard via Turbo Streams
Called after Amazon operations complete (pull buy box, pull catalog info, etc.)

Parameters:

  • skip_amazon_check (Boolean) (defaults to: false)

    Skip the amazon_catalog_item? check (useful when caller already verified)



1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 1014

def broadcast_amazon_dashboard_update(skip_amazon_check: false)
  return unless skip_amazon_check || amazon_catalog_item?

  Rails.logger.info "[CatalogItem] Broadcasting Amazon dashboard update for #{id}"

  # Broadcast updates to each cell that may have changed
  cells = {
    "amazon_item_status_#{id}" => 'crm/amazon_products/status_cell',
    "amazon_item_price_#{id}" => 'crm/amazon_products/price_cell',
    "amazon_item_minmax_#{id}" => 'crm/amazon_products/minmax_cell',
    "amazon_item_comp_price_#{id}" => 'crm/amazon_products/competitive_price_cell',
    "amazon_item_buybox_#{id}" => 'crm/amazon_products/buybox_cell',
    "amazon_item_fm_#{id}" => 'crm/amazon_products/fm_cell',
    "amazon_item_issues_#{id}" => 'crm/amazon_products/issues_cell',
    "amazon_item_price_updated_#{id}" => 'crm/amazon_products/price_updated_cell',
    "amazon_item_data_#{id}" => 'crm/amazon_products/data_freshness_cell'
  }

  stream = "amazon_catalog_items:catalog_#{catalog_id}"
  cells.each do |target, partial|
    Turbo::StreamsChannel.broadcast_update_to(
      stream,
      target: target,
      partial: partial,
      locals: { catalog_item: self }
    )
  end
rescue StandardError => e
  Rails.logger.warn "[CatalogItem] Amazon dashboard broadcast failed: #{e.message}"
end

#extract_amazon_procurement_cost_priceFloat?

Extracts the procurement cost price from retailer_information.

Returns:

  • (Float, nil)


722
723
724
725
726
727
728
729
730
731
732
733
734
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 722

def extract_amazon_procurement_cost_price
  return unless amazon_catalog_item?

  amz_locale = retailer_information&.keys&.first
  return unless amz_locale

  begin
    hsh = retailer_information&.fetch(amz_locale)&.fetch('procurement')&.first&.fetch('costPrice')
    hsh&.fetch('amount').to_f
  rescue KeyError
    hsh&.to_s
  end
end

#extract_retailer_information(amz_attribute_sym) ⇒ Object?

Extracts a single attribute value from retailer_information.

Parameters:

  • amz_attribute_sym (Symbol, String)

    the Amazon attribute name

Returns:

  • (Object, nil)

    the attribute value, or nil if unavailable



706
707
708
709
710
711
712
713
714
715
716
717
718
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 706

def extract_retailer_information(amz_attribute_sym)
  return unless amazon_catalog_item?

  amz_locale = retailer_information&.keys&.first
  return unless amz_locale

  begin
    hsh = retailer_information&.fetch(amz_locale)&.fetch('attributes')&.fetch(amz_attribute_sym.to_s)&.first
    hsh&.fetch('value')
  rescue KeyError
    hsh&.to_s
  end
end

#fba_discountFloat

This allows you to control the discount applied to the FBA price
However I noticed that if you have no FBA stock, the FBM offer doesn't automatically
take over and detects the FBA as a competitive price, so it is better to match
them and let amazon decides which to present

Returns:

  • (Float)


918
919
920
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 918

def fba_discount
  0.0
end

#get_amz_all_patches(fba: false, language_tag: nil) ⇒ Array<Hash>

Returns all Amazon attribute patches for this catalog item
Useful for debugging to see what would be sent to Amazon

Parameters:

  • fba (Boolean) (defaults to: false)

    Whether to use FBA logic

  • language_tag (String, nil) (defaults to: nil)

    Override language tag

Returns:

  • (Array<Hash>)

    Array of patch objects



612
613
614
615
616
617
618
619
620
621
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 612

def get_amz_all_patches(fba: false, language_tag: nil)
  return [] unless amazon_catalog_item?

  begin
    amazon_json_generator(fba:, language_tag:).get_patches
  rescue StandardError => e
    Rails.logger.error "[CatalogItem] Failed to get Amazon patches: #{e.class} - #{e.message}"
    []
  end
end

#get_amz_attribute(attribute_name, fba: false, language_tag: nil, format: :patch, operation: :replace) ⇒ Hash, ...

Retrieves a single Amazon attribute's PATCH JSON through the full listing generator stack.

Examples:

Get the wattage patch JSON

catalog_item.get_amz_attribute('wattage')
# => { op: :replace, path: "/attributes/wattage", value: [{ value: 150, unit: "watts", marketplace_id: "ATVPDKIKX0DER" }] }

Get just the built value array

catalog_item.get_amz_attribute('item_name', format: :value)
# => [{ value: "Floor Heating Mat 120V", language_tag: "en_US", marketplace_id: "ATVPDKIKX0DER" }]

Get all available attributes

catalog_item.amz_available_attributes
# => ["wattage", "voltage", "item_name", ...]

Parameters:

  • attribute_name (String, Symbol)

    The attribute name (e.g., 'wattage', 'item_name', 'voltage')

  • fba (Boolean) (defaults to: false)

    Whether to use FBA logic (default: false)

  • language_tag (String, nil) (defaults to: nil)

    Override language tag (default: uses catalog's amazon_locales)

  • format (Symbol) (defaults to: :patch)

    Output format - :patch (full patch JSON), :value (just the built value array), :raw (raw value before Amazon formatting)

  • operation (Symbol) (defaults to: :replace)

    The patch operation for :patch format (default: :replace; :delete emits a delete patch)

Returns:

  • (Hash, Array, Object, nil)

    The attribute data in the requested format, or nil if unavailable



558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 558

def get_amz_attribute(attribute_name, fba: false, language_tag: nil, format: :patch, operation: :replace)
  return nil unless amazon_catalog_item?

  attribute_name = attribute_name.to_s.underscore.to_sym
  attribute_actions = { attribute_name => operation }

  begin
    generator = amazon_json_generator(attribute_actions:, fba:, language_tag:)
    attribute_builder = Edi::Amazon::JsonListingGenerator::Attributes::AttributeFactory.build(
      attribute_name,
      catalog_item: self,
      variation: nil,
      language_tag: generator.language_tag,
      enum_mapper: generator.enum_mapper,
      marketplace_id: generator.marketplace_id,
      fba: fba
    )

    built_value = attribute_builder.build

    case format
    when :raw
      attribute_builder.value
    when :value
      built_value
    when :patch
      return nil if built_value.blank? && operation != :delete

      {
        op: operation,
        path: "/attributes/#{attribute_name}",
        value: operation == :delete ? [{ marketplace_id: generator.marketplace_id }] : built_value
      }
    else
      built_value
    end
  rescue NameError => e
    Rails.logger.warn "[CatalogItem] Amazon attribute '#{attribute_name}' not found: #{e.message}"
    nil
  rescue ArgumentError => e
    Rails.logger.warn "[CatalogItem] Amazon attribute '#{attribute_name}' failed to initialize: #{e.message}"
    nil
  rescue StandardError => e
    Rails.logger.error "[CatalogItem] Amazon attribute '#{attribute_name}' error: #{e.class} - #{e.message}"
    nil
  end
end

#get_amz_attributes(attribute_names, fba: false, language_tag: nil, format: :patch) ⇒ Hash

Retrieves multiple Amazon attributes at once

Examples:

Get multiple attributes

catalog_item.get_amz_attributes(['wattage', 'voltage', 'item_name'])
# => { wattage: {...}, voltage: {...}, item_name: {...} }

Parameters:

  • attribute_names (Array<String, Symbol>)

    List of attribute names to retrieve

  • fba (Boolean) (defaults to: false)

    Whether to use FBA logic

  • language_tag (String, nil) (defaults to: nil)

    Override language tag

  • format (Symbol) (defaults to: :patch)

    Output format - :patch, :value, or :raw

Returns:

  • (Hash)

    Hash of attribute_name => result



641
642
643
644
645
646
647
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 641

def get_amz_attributes(attribute_names, fba: false, language_tag: nil, format: :patch)
  return {} unless amazon_catalog_item?

  attribute_names.to_h do |attr_name|
    [attr_name.to_sym, get_amz_attribute(attr_name, fba:, language_tag:, format:)]
  end
end

#has_amazon_fba?Boolean

Whether this catalog item has Amazon FBA configured with a labeling choice.

Returns:

  • (Boolean)


97
98
99
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 97

def has_amazon_fba?
  amazon_fba_sku.present? && (amazon_fba_labeling_upc? || amazon_fba_labeling_fnsku?)
end

#map_price_with_taxBigDecimal, ...

MAP in the same tax-inclusive units as the rest of this surface.

CatalogItem#map_price is derived from msrp, which is the root catalog
item's amount and therefore pre-tax. Comparing it raw against a
tax-inclusive minimum understates MAP wherever tax_rate is positive (the
EU catalogs), and the lowering service divides the chosen floor by
1 + tax_rate before storing amount — so a raw comparison could store a
price below MAP, which is the one thing the floor exists to prevent.

Returns:

  • (BigDecimal, Float, nil)


894
895
896
897
898
899
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 894

def map_price_with_tax
  return if map_price.blank?
  return map_price unless tax_rate

  ((tax_rate + 1) * map_price).round(2)
end

#max_discount_allowedHash{Symbol => Float}

Computes the maximum discount that still meets the minimum profit margin.

Returns:

  • (Hash{Symbol => Float})

    :max_discount_dollars and :max_discount_percentage



972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 972

def max_discount_allowed
  # Calculate the minimum sale price required to meet the minimum profit margin
  minimum_sale_price = amazon_item_cost / (1 - (amazon_minimum_profit_margin / 100.0))

  # Calculate the maximum discount in dollars, 0 being the maximum
  max_discount = [msrp - minimum_sale_price, 0.0].max

  # Calculate the maximum discount as a percentage of MSRP
  max_discount_percentage = ((max_discount / msrp) * 100).round(2)

  # Return both dollar and percentage values
  {
    max_discount_dollars: max_discount.round(2),
    max_discount_percentage: max_discount_percentage
  }
end

#max_discount_allowed_dollarsFloat

The dollar component of #max_discount_allowed.

Returns:

  • (Float)


991
992
993
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 991

def max_discount_allowed_dollars
  max_discount_allowed[:max_discount_dollars]
end

#max_discount_allowed_percentageFloat

The percentage component of #max_discount_allowed.

Returns:

  • (Float)


997
998
999
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 997

def max_discount_allowed_percentage
  max_discount_allowed[:max_discount_percentage]
end

#profit_margin_amazon_minimum_below_targetBoolean

Whether the minimum allowed price yields a margin below the target.

Returns:

  • (Boolean)


954
955
956
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 954

def profit_margin_amazon_minimum_below_target
  profit_marging_amazon_minimum_seller_allowed_price < amazon_minimum_profit_margin
end

#profit_marging_amazon_maximum_seller_allowed_priceBigDecimal, ...

The profit margin implied by the maximum allowed Amazon price.

Returns:

  • (BigDecimal, Float, nil)


960
961
962
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 960

def profit_marging_amazon_maximum_seller_allowed_price
  calculate_profit_margin amazon_maximum_seller_allowed_price_with_tax
end

#profit_marging_amazon_minimum_seller_allowed_priceBigDecimal, ...

The profit margin implied by the minimum allowed Amazon price.

Returns:

  • (BigDecimal, Float, nil)


948
949
950
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 948

def profit_marging_amazon_minimum_seller_allowed_price
  calculate_profit_margin amazon_minimum_seller_allowed_price_with_tax
end

#ready_to_print_amazon_fba_labels?Boolean

Whether this item is ready to print Amazon FBA labels.

Returns:

  • (Boolean)


747
748
749
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 747

def ready_to_print_amazon_fba_labels?
  catalog.amazon_catalog? && amazon_label_requirements.blank?
end

#reprice_allows_lowering?Boolean

Returns whether the rule routes through the lowering service.

Returns:

  • (Boolean)

    whether the rule routes through the lowering service



122
123
124
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 122

def reprice_allows_lowering?
  reprice_rule.in?(CatalogItem::REPRICE_RULES_LOWERING)
end

#reprice_allows_raising?Boolean

Returns whether the rule routes through the raising service.

Returns:

  • (Boolean)

    whether the rule routes through the raising service



117
118
119
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 117

def reprice_allows_raising?
  reprice_rule.in?(CatalogItem::REPRICE_RULES_RAISING)
end

#reprice_revert_due?Boolean

A raise from the automation is still "on probation": we lost the Buy Box
within the revert window, so the raise is deemed the cause and gets rolled
back rather than chased back down by the lowering service.

Returns:

  • (Boolean)


135
136
137
138
139
140
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 135

def reprice_revert_due?
  reprice_reverts_on_buy_box_loss? &&
    !is_amz_buy_box_winner &&
    last_raise_prior_amount.present? &&
    last_raised_at.present? && last_raised_at > CatalogItem::REPRICE_REVERT_WINDOW.ago
end

#reprice_revert_last_raise!(whodunnit:) ⇒ BigDecimal

Roll back the last automated raise and clear the bookkeeping. Regaining
the Buy Box resets amz_last_buy_box_winner_change, so the raising
service's stability gate is the natural cooldown before the next attempt.

Parameters:

  • whodunnit (String)

    PaperTrail actor recorded for the price change

Returns:

  • (BigDecimal)

    the restored amount



147
148
149
150
151
152
153
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 147

def reprice_revert_last_raise!(whodunnit:)
  prior = last_raise_prior_amount
  PaperTrail.request(whodunnit: whodunnit) do
    update!(amount: prior, last_raise_prior_amount: nil, last_raised_at: nil)
  end
  prior
end

#reprice_reverts_on_buy_box_loss?Boolean

Returns whether losing the Buy Box after a raise rolls the price back.

Returns:

  • (Boolean)

    whether losing the Buy Box after a raise rolls the price back



127
128
129
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 127

def reprice_reverts_on_buy_box_loss?
  reprice_rule_up_only_preserve_buy_box? || reprice_rule_auto_buy_box?
end

#reprice_settings_amazon_onlyvoid

This method returns an undefined value.

Validation: reprice settings may only move off their defaults on Amazon
catalog items. A record with no catalog yet skips this check — the
belongs_to presence validation reports that on its own.
(Not private — methods below must stay public.)



160
161
162
163
164
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 160

def reprice_settings_amazon_only
  return if catalog.blank? || amazon_catalog_item?

  errors.add(:reprice_rule, 'repricing settings only apply to Amazon catalog items')
end

#reprice_settings_customized?Boolean

Any reprice setting moved off its column default?

Returns:

  • (Boolean)


103
104
105
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 103

def reprice_settings_customized?
  reprice_rule != 'auto_buy_box' || reprice_target_price.present?
end

#reset_reprice_raise_bookkeepingvoid

This method returns an undefined value.

Clear the raise-streak bookkeeping (see the before_save above).



109
110
111
112
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 109

def reset_reprice_raise_bookkeeping
  self.last_raise_prior_amount = nil
  self.last_raised_at = nil
end

#retailer_information_first_localeHash

The first locale hash from retailer_information, or an empty hash.

Returns:

  • (Hash)


407
408
409
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 407

def retailer_information_first_locale
  retailer_information&.with_indifferent_access&.values&.first || {}
end

#title_for_amazonObject

Alias for
to: :item#title_for_amazon

Returns:

  • (Object)
             to: :item#title_for_amazon
    

See Also:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'app/concerns/models/catalog_item_amazon_helper.rb', line 77

delegate :title_for_amazon,
:amazon_title,
:amazon_description,
:amazon_generic_keyword,
:amazon_target_keywords,
:amazon_feature_1,
:amazon_feature_2,
:amazon_feature_3,
:amazon_feature_4,
:amazon_feature_5,
:amazon_feature_6,
:amazon_feature_7,
:amazon_feature_8,
:amazon_feature_9,
allow_nil: true,
to: :item