spree_core 5.4.3 → 5.5.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +0 -82
  3. data/app/helpers/spree/currency_helper.rb +0 -12
  4. data/app/helpers/spree/products_helper.rb +0 -8
  5. data/app/jobs/spree/base_job.rb +18 -0
  6. data/app/jobs/spree/events/subscriber_job.rb +2 -1
  7. data/app/jobs/spree/exports/generate_job.rb +11 -0
  8. data/app/jobs/spree/images/save_from_url_job.rb +23 -8
  9. data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
  10. data/app/jobs/spree/imports/base_job.rb +15 -0
  11. data/app/jobs/spree/imports/create_categories_job.rb +37 -0
  12. data/app/jobs/spree/imports/create_rows_job.rb +1 -3
  13. data/app/jobs/spree/imports/process_group_job.rb +8 -6
  14. data/app/jobs/spree/imports/process_rows_job.rb +1 -3
  15. data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
  16. data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
  17. data/app/jobs/spree/reports/generate_job.rb +11 -0
  18. data/app/jobs/spree/search_provider/index_job.rb +5 -1
  19. data/app/jobs/spree/search_provider/remove_job.rb +4 -0
  20. data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
  21. data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
  22. data/app/models/concerns/spree/display_on.rb +31 -0
  23. data/app/models/concerns/spree/metafields.rb +167 -5
  24. data/app/models/concerns/spree/preference_schema.rb +191 -0
  25. data/app/models/concerns/spree/prefixed_id.rb +94 -11
  26. data/app/models/concerns/spree/product_scopes.rb +36 -17
  27. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  28. data/app/models/concerns/spree/search_indexable.rb +8 -7
  29. data/app/models/concerns/spree/searchable.rb +11 -2
  30. data/app/models/concerns/spree/stores/channels.rb +20 -0
  31. data/app/models/concerns/spree/stores/markets.rb +21 -5
  32. data/app/models/concerns/spree/typed_associations.rb +120 -0
  33. data/app/models/concerns/spree/user_methods.rb +71 -12
  34. data/app/models/spree/ability.rb +4 -117
  35. data/app/models/spree/api_key.rb +53 -0
  36. data/app/models/spree/asset.rb +28 -5
  37. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  38. data/app/models/spree/base.rb +18 -1
  39. data/app/models/spree/channel.rb +159 -0
  40. data/app/models/spree/country.rb +2 -0
  41. data/app/models/spree/current.rb +5 -1
  42. data/app/models/spree/custom_field.rb +9 -0
  43. data/app/models/spree/custom_field_definition.rb +7 -0
  44. data/app/models/spree/customer_group.rb +8 -2
  45. data/app/models/spree/export.rb +30 -3
  46. data/app/models/spree/gateway.rb +25 -0
  47. data/app/models/spree/gift_card.rb +1 -1
  48. data/app/models/spree/gift_card_batch.rb +4 -1
  49. data/app/models/spree/import.rb +5 -0
  50. data/app/models/spree/import_row.rb +12 -0
  51. data/app/models/spree/line_item.rb +6 -1
  52. data/app/models/spree/market.rb +32 -1
  53. data/app/models/spree/metafield.rb +38 -0
  54. data/app/models/spree/metafield_definition.rb +29 -6
  55. data/app/models/spree/metafields/json.rb +10 -0
  56. data/app/models/spree/newsletter_subscriber.rb +19 -3
  57. data/app/models/spree/option_type.rb +48 -7
  58. data/app/models/spree/order/checkout.rb +3 -3
  59. data/app/models/spree/order.rb +102 -6
  60. data/app/models/spree/order_approval.rb +19 -0
  61. data/app/models/spree/order_cancellation.rb +19 -0
  62. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  63. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  64. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  65. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  66. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  67. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  68. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  69. data/app/models/spree/order_routing/strategy/rules.rb +81 -0
  70. data/app/models/spree/order_routing_rule.rb +75 -0
  71. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  72. data/app/models/spree/permission_sets/product_display.rb +2 -0
  73. data/app/models/spree/permission_sets/product_management.rb +2 -0
  74. data/app/models/spree/price.rb +14 -1
  75. data/app/models/spree/price_list.rb +129 -17
  76. data/app/models/spree/price_rule.rb +11 -1
  77. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  78. data/app/models/spree/price_rules/market_rule.rb +16 -1
  79. data/app/models/spree/price_rules/user_rule.rb +21 -2
  80. data/app/models/spree/product/channels.rb +149 -0
  81. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  82. data/app/models/spree/product/slugs.rb +1 -1
  83. data/app/models/spree/product.rb +172 -31
  84. data/app/models/spree/product_publication.rb +43 -0
  85. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  86. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  87. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  88. data/app/models/spree/promotion/rules/country.rb +40 -18
  89. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  90. data/app/models/spree/promotion/rules/product.rb +4 -0
  91. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  92. data/app/models/spree/promotion/rules/user.rb +21 -0
  93. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  94. data/app/models/spree/promotion.rb +22 -1
  95. data/app/models/spree/promotion_action.rb +17 -11
  96. data/app/models/spree/promotion_rule.rb +17 -18
  97. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  98. data/app/models/spree/stock/availability_validator.rb +1 -1
  99. data/app/models/spree/stock/quantifier.rb +89 -9
  100. data/app/models/spree/stock_item.rb +36 -0
  101. data/app/models/spree/stock_location.rb +52 -0
  102. data/app/models/spree/stock_reservation.rb +38 -0
  103. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  104. data/app/models/spree/store.rb +18 -72
  105. data/app/models/spree/store_credit.rb +0 -8
  106. data/app/models/spree/store_product.rb +11 -23
  107. data/app/models/spree/taxon.rb +0 -5
  108. data/app/models/spree/user_identity.rb +1 -2
  109. data/app/models/spree/variant.rb +132 -18
  110. data/app/models/spree/variant_media.rb +46 -0
  111. data/app/models/spree/webhook_delivery.rb +1 -1
  112. data/app/models/spree/webhook_endpoint.rb +24 -0
  113. data/app/models/spree/wished_item.rb +0 -13
  114. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  115. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  116. data/app/presenters/spree/variant_presenter.rb +4 -3
  117. data/app/services/spree/addresses/update.rb +6 -8
  118. data/app/services/spree/cart/add_item.rb +10 -0
  119. data/app/services/spree/cart/empty.rb +2 -0
  120. data/app/services/spree/cart/remove_line_item.rb +10 -0
  121. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  122. data/app/services/spree/cart/set_quantity.rb +10 -0
  123. data/app/services/spree/carts/complete.rb +1 -0
  124. data/app/services/spree/carts/create.rb +1 -0
  125. data/app/services/spree/carts/update.rb +18 -2
  126. data/app/services/spree/carts/upsert_items.rb +6 -6
  127. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  128. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  129. data/app/services/spree/newsletter/link_user.rb +53 -0
  130. data/app/services/spree/newsletter/subscribe.rb +31 -9
  131. data/app/services/spree/orders/approve.rb +27 -6
  132. data/app/services/spree/orders/build_shipments.rb +29 -0
  133. data/app/services/spree/orders/cancel.rb +34 -3
  134. data/app/services/spree/orders/complete.rb +53 -0
  135. data/app/services/spree/orders/create.rb +156 -0
  136. data/app/services/spree/orders/update.rb +51 -0
  137. data/app/services/spree/orders/upsert_items.rb +70 -0
  138. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  139. data/app/services/spree/products/duplicator.rb +1 -1
  140. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  141. data/app/services/spree/sample_data/loader.rb +30 -0
  142. data/app/services/spree/stock_reservations/extend.rb +19 -0
  143. data/app/services/spree/stock_reservations/release.rb +12 -0
  144. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  145. data/app/services/spree/taxons/remove_products.rb +7 -1
  146. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  147. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  148. data/config/locales/en.yml +27 -10
  149. data/config/routes.rb +9 -0
  150. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  151. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  152. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  153. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  154. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  155. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  156. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  157. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  158. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  159. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  160. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  161. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  162. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  163. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  164. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  165. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  166. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  167. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  168. data/db/sample_data/channels.rb +12 -0
  169. data/db/sample_data/orders.rb +1 -1
  170. data/db/sample_data/products.csv +212 -212
  171. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  172. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  173. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  174. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  175. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  176. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  177. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  178. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  179. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
  180. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  181. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  182. data/lib/generators/spree/model/model_generator.rb +73 -7
  183. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  184. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  185. data/lib/spree/core/configuration.rb +7 -0
  186. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  187. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  188. data/lib/spree/core/controller_helpers/order.rb +0 -19
  189. data/lib/spree/core/dependencies.rb +5 -2
  190. data/lib/spree/core/engine.rb +54 -7
  191. data/lib/spree/core/permission_configuration.rb +15 -0
  192. data/lib/spree/core/preferences/masking.rb +47 -0
  193. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  194. data/lib/spree/core/version.rb +1 -1
  195. data/lib/spree/core.rb +56 -5
  196. data/lib/spree/permitted_attributes.rb +9 -7
  197. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  198. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  199. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  200. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  201. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  202. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  203. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  204. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  205. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  206. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  207. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  208. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  209. data/lib/spree/testing_support/store.rb +10 -0
  210. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  211. data/lib/tasks/channels.rake +94 -0
  212. data/lib/tasks/core.rake +1 -0
  213. data/lib/tasks/media.rake +27 -0
  214. data/lib/tasks/products.rake +4 -6
  215. data/lib/tasks/publications.rake +60 -0
  216. data/lib/tasks/upgrade.rake +211 -0
  217. metadata +83 -18
  218. data/app/finders/spree/variants/visible_finder.rb +0 -23
  219. data/app/paginators/spree/shared/paginate.rb +0 -30
  220. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  221. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  222. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  223. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  224. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  225. data/app/services/spree/classifications/reposition.rb +0 -23
  226. data/app/sorters/spree/orders/sort.rb +0 -10
  227. data/lib/spree/core/controller_helpers/common.rb +0 -14
  228. data/lib/spree/core/token_generator.rb +0 -23
  229. data/lib/spree/database_type_utilities.rb +0 -22
  230. data/lib/spree/testing_support/bar_ability.rb +0 -14
  231. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ class StockReservation < Spree.base_class
3
+ has_prefix_id :res
4
+
5
+ publishes_lifecycle_events
6
+
7
+ belongs_to :stock_item, class_name: 'Spree::StockItem', inverse_of: :stock_reservations
8
+ belongs_to :line_item, class_name: 'Spree::LineItem', inverse_of: :stock_reservations
9
+ belongs_to :order, class_name: 'Spree::Order', inverse_of: :stock_reservations
10
+
11
+ validates :stock_item, :line_item, :order, :quantity, :expires_at, presence: true
12
+ validates :quantity, numericality: { greater_than: 0, only_integer: true }, presence: true
13
+ validates :line_item_id, uniqueness: { scope: :stock_item_id }, presence: true
14
+
15
+ scope :active, -> { where('spree_stock_reservations.expires_at > ?', Time.current) }
16
+ scope :expired, -> { where('spree_stock_reservations.expires_at <= ?', Time.current) }
17
+ scope :for_order, ->(order) { where(order_id: order.id) }
18
+ scope :for_store, ->(store) {
19
+ joins(:order).where(spree_orders: { store_id: store.id })
20
+ }
21
+
22
+ self.whitelisted_ransackable_attributes = %w[stock_item_id line_item_id order_id quantity expires_at]
23
+ self.whitelisted_ransackable_associations = %w[stock_item line_item order]
24
+
25
+ def active?
26
+ expires_at > Time.current
27
+ end
28
+
29
+ # Resolves the reservation TTL: per-Store preference if set, otherwise
30
+ # the global Spree::Config[:default_stock_reservation_ttl_minutes]. Falls
31
+ # back to 10 minutes if both are unset (e.g. early-boot / fixture state).
32
+ def self.ttl_for(order)
33
+ minutes = order&.store&.preferred_stock_reservation_ttl_minutes
34
+ minutes = Spree::Config[:default_stock_reservation_ttl_minutes] if minutes.blank?
35
+ minutes.to_i.then { |m| m > 0 ? m : 10 }.minutes
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ module StockReservations
3
+ class InsufficientStockError < StandardError
4
+ attr_reader :line_item
5
+
6
+ def initialize(line_item, message)
7
+ @line_item = line_item
8
+ super(message)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -12,8 +12,10 @@ module Spree
12
12
  include Spree::Metadata
13
13
  include Spree::Stores::Setup
14
14
  include Spree::Stores::Markets
15
+ include Spree::Stores::Channels
15
16
  include Spree::Security::Stores if defined?(Spree::Security::Stores)
16
17
  include Spree::UserManagement
18
+ include Spree::OrderRouting::HasStrategyPreference
17
19
 
18
20
  #
19
21
  # Magic methods
@@ -46,6 +48,7 @@ module Spree
46
48
  # Checkout preferences
47
49
  preference :guest_checkout, :boolean, default: true
48
50
  preference :special_instructions_enabled, :boolean, default: false
51
+ preference :stock_reservation_ttl_minutes, :integer, default: 10
49
52
  # Address preferences
50
53
  preference :company_field_enabled, :boolean, default: false
51
54
  # digital assets preferences
@@ -54,6 +57,9 @@ module Spree
54
57
  preference :digital_asset_authorized_clicks, :integer, default: 5
55
58
  preference :digital_asset_authorized_days, :integer, default: 7
56
59
  preference :digital_asset_link_expire_time, :integer, default: 300
60
+ # Class name of the Spree::OrderRouting::Strategy::Base subclass that
61
+ # decides which StockLocation fulfills which items.
62
+ preference :order_routing_strategy, :string, default: 'Spree::OrderRouting::Strategy::Rules'
57
63
 
58
64
  #
59
65
  # Associations
@@ -71,10 +77,11 @@ module Spree
71
77
  has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod'
72
78
  has_many :payment_methods, through: :store_payment_methods, class_name: 'Spree::PaymentMethod'
73
79
 
74
- has_many :store_products, class_name: 'Spree::StoreProduct'
75
- has_many :products, through: :store_products, class_name: 'Spree::Product'
80
+ has_many :products, class_name: 'Spree::Product', dependent: :nullify
81
+ has_many :product_publications, through: :channels, source: :publications, class_name: 'Spree::ProductPublication'
76
82
  has_many :variants, through: :products, class_name: 'Spree::Variant', source: :variants_including_master
77
83
  has_many :stock_items, through: :variants, class_name: 'Spree::StockItem'
84
+ has_many :prices, through: :variants, class_name: 'Spree::Price'
78
85
  has_many :inventory_units, through: :variants, class_name: 'Spree::InventoryUnit'
79
86
  has_many :option_value_variants, through: :variants, class_name: 'Spree::OptionValueVariant'
80
87
  has_many :customer_returns, class_name: 'Spree::CustomerReturn', inverse_of: :store
@@ -108,6 +115,9 @@ module Spree
108
115
  has_many :webhook_endpoints, class_name: 'Spree::WebhookEndpoint', dependent: :destroy, inverse_of: :store
109
116
  has_many :webhook_deliveries, through: :webhook_endpoints, class_name: 'Spree::WebhookDelivery'
110
117
 
118
+ has_many :channels, class_name: 'Spree::Channel', dependent: :destroy
119
+ has_many :order_routing_rules, through: :channels, class_name: 'Spree::OrderRoutingRule'
120
+
111
121
  has_many :customer_groups, class_name: 'Spree::CustomerGroup', dependent: :destroy, inverse_of: :store
112
122
 
113
123
  has_many :api_keys, class_name: 'Spree::ApiKey', dependent: :destroy
@@ -121,6 +131,7 @@ module Spree
121
131
  end
122
132
  validates :preferred_digital_asset_authorized_clicks, numericality: { only_integer: true, greater_than: 0 }
123
133
  validates :preferred_digital_asset_authorized_days, numericality: { only_integer: true, greater_than: 0 }
134
+ validates :preferred_stock_reservation_ttl_minutes, numericality: { only_integer: true, greater_than: 0 }
124
135
  validates :mail_from_address, email: { allow_blank: false }
125
136
  # FIXME: we should remove this condition in v5
126
137
  if !ENV['SPREE_DISABLE_DB_CONNECTION'] &&
@@ -141,7 +152,6 @@ module Spree
141
152
  # Callbacks
142
153
  before_validation :set_default_code, on: :create
143
154
  before_save :ensure_default_exists_and_is_unique
144
- after_create :ensure_default_market
145
155
  after_create :create_default_policies
146
156
 
147
157
  #
@@ -165,7 +175,7 @@ module Spree
165
175
  else
166
176
  Spree::Deprecation.warn(
167
177
  'Spree::Store.default returning a new unpersisted store when no default store exists is deprecated ' \
168
- 'and will be removed in Spree 5.5. Please ensure a default store is created before calling Store.default.'
178
+ 'and will be removed in Spree 6.0. Please ensure a default store is created before calling Store.default.'
169
179
  )
170
180
  new(default: true)
171
181
  end
@@ -175,6 +185,10 @@ module Spree
175
185
  Spree::Store.default&.supported_locales_list || []
176
186
  end
177
187
 
188
+ def default_channel
189
+ channels.find_by(code: Spree::Channel::DEFAULT_CODE) || channels.active.first
190
+ end
191
+
178
192
  # @deprecated Use Markets instead. Will be removed in Spree 5.5.
179
193
  def checkout_zone
180
194
  Spree::Deprecation.warn('Store#checkout_zone is deprecated and will be removed in Spree 5.5. Use Markets instead.')
@@ -366,25 +380,6 @@ module Spree
366
380
 
367
381
  private
368
382
 
369
- def ensure_default_market
370
- return if markets.exists?
371
-
372
- country = @default_country_for_market
373
- return if country.blank?
374
-
375
- iso_country = ISO3166::Country[country.iso]
376
-
377
- Spree::Events.disable do
378
- markets.create!(
379
- name: country.name,
380
- currency: iso_country&.currency_code || read_attribute(:default_currency) || 'USD',
381
- default_locale: iso_country&.languages_official&.first || read_attribute(:default_locale) || 'en',
382
- default: true,
383
- countries: [country]
384
- )
385
- end
386
- end
387
-
388
383
  def create_default_policies
389
384
  Spree::Events.disable do
390
385
  [
@@ -401,55 +396,6 @@ module Spree
401
396
  end
402
397
  end
403
398
 
404
- def ensure_default_taxonomies_are_created
405
- Spree::Deprecation.warn('Store#ensure_default_taxonomies_are_created is deprecated and will be removed in Spree 5.5. Please remove it from your codebase')
406
-
407
- Spree::Events.disable do
408
- [
409
- translate_with_store_locale_fallback('spree.taxonomy_categories_name'),
410
- translate_with_store_locale_fallback('spree.taxonomy_brands_name'),
411
- translate_with_store_locale_fallback('spree.taxonomy_collections_name')
412
- ].each do |taxonomy_name|
413
- # Manual exists?/create to work around Mobility bug with find_or_create_by
414
- next if taxonomies.with_matching_name(taxonomy_name).exists?
415
-
416
- taxonomies.create(name: taxonomy_name)
417
- end
418
- end
419
- end
420
-
421
- def ensure_default_automatic_taxons
422
- Spree::Deprecation.warn('Store#ensure_default_automatic_taxons is deprecated and will be removed in Spree 5.5. Please remove it from your codebase')
423
-
424
- Spree::Events.disable do
425
- # Use Mobility-safe lookup for taxonomy
426
- collections_taxonomy = taxonomies.with_matching_name(translate_with_store_locale_fallback('spree.taxonomy_collections_name')).first
427
- return unless collections_taxonomy.present?
428
-
429
- automatic_taxons_config = [
430
- { name: translate_with_store_locale_fallback('spree.automatic_taxon_names.on_sale'),
431
- rule_type: 'Spree::TaxonRules::Sale', rule_value: 'true' },
432
- { name: translate_with_store_locale_fallback('spree.automatic_taxon_names.new_arrivals'), rule_type: 'Spree::TaxonRules::AvailableOn', rule_value: 30 }
433
- ]
434
-
435
- automatic_taxons_config.map do |config|
436
- # Manual exists?/create to work around Mobility bug with first_or_create
437
- taxon_scope = collections_taxonomy.taxons.automatic.with_matching_name(config[:name])
438
-
439
- if taxon_scope.exists?
440
- taxon_scope.first
441
- else
442
- collections_taxonomy.taxons.create!(
443
- name: config[:name],
444
- automatic: true,
445
- parent: collections_taxonomy.root,
446
- taxon_rules: [TaxonRule.new(type: config[:rule_type], value: config[:rule_value])]
447
- )
448
- end
449
- end
450
- end
451
- end
452
-
453
399
  # Translates a key using the store's default locale with fallback to :en
454
400
  def translate_with_store_locale_fallback(key)
455
401
  locale = default_locale.presence&.to_sym || :en
@@ -189,14 +189,6 @@ module Spree
189
189
  "#{id}-SC-#{Time.now.utc.strftime('%Y%m%d%H%M%S%6N')}"
190
190
  end
191
191
 
192
- class << self
193
- def default_created_by
194
- Spree::Deprecation.warn('StoreCredit#default_created_by is deprecated and will be removed in Spree 5.5. Please use store.users.first instead.')
195
-
196
- Spree::Store.current.users.first
197
- end
198
- end
199
-
200
192
  private
201
193
 
202
194
  def create_credit_record(amount, action_attributes = {})
@@ -1,29 +1,17 @@
1
1
  module Spree
2
+ # Thin AR wrapper over the legacy +spree_products_stores+ join table.
3
+ # Pre-5.5 core used this table to attach products to stores; 5.5+ moved
4
+ # that responsibility onto +Spree::Product#store_id+ + +ProductPublication+.
5
+ #
6
+ # The model exists only to power the 5.4 → 5.5 backfill rake task
7
+ # (+spree:upgrade:populate_publications+). Host apps upgrading from 5.4
8
+ # still have the table; after the backfill runs, +spree_multi_store+ (for
9
+ # multi-store catalogs) keeps the table around, and single-store
10
+ # installations may drop it.
2
11
  class StoreProduct < Spree.base_class
3
- has_prefix_id :sp
4
-
5
12
  self.table_name = 'spree_products_stores'
6
13
 
7
- belongs_to :store, class_name: 'Spree::Store', touch: true
8
- belongs_to :product, class_name: 'Spree::Product', touch: true
9
-
10
- validates :store, :product, presence: true
11
- validates :store_id, uniqueness: { scope: :product_id }
12
-
13
- def refresh_metrics!
14
- return if product.nil?
15
-
16
- completed_order_ids = product.completed_orders.where(store_id: store_id).select(:id)
17
- variant_ids = product.variants_including_master.ids
18
-
19
- line_items = Spree::LineItem.joins(:order)
20
- .where(spree_orders: { id: completed_order_ids })
21
- .where(variant_id: variant_ids)
22
-
23
- update!(
24
- units_sold_count: line_items.sum(:quantity),
25
- revenue: line_items.sum(:pre_tax_amount)
26
- )
27
- end
14
+ belongs_to :product, class_name: 'Spree::Product'
15
+ belongs_to :store, class_name: 'Spree::Store'
28
16
  end
29
17
  end
@@ -340,11 +340,6 @@ module Spree
340
340
  end
341
341
  end
342
342
 
343
- def active_products
344
- Spree::Deprecation.warn('active_products is deprecated and will be removed in Spree 5.5. Please use taxon.products.active instead.')
345
- products.active
346
- end
347
-
348
343
  def regenerate_pretty_name_and_permalink
349
344
  Mobility.with_locale(nil) do
350
345
  update_columns(pretty_name: generate_pretty_name, permalink: generate_slug, updated_at: Time.current)
@@ -9,8 +9,7 @@ module Spree
9
9
 
10
10
  validates :provider, inclusion: {
11
11
  in: ->(_record) {
12
- config = Rails.application.config.spree
13
- (config.store_authentication_strategies.keys + config.admin_authentication_strategies.keys).uniq.map(&:to_s)
12
+ (Spree.store_authentication_strategies.keys + Spree.admin_authentication_strategies.keys).uniq.map(&:to_s)
14
13
  }
15
14
  }
16
15
 
@@ -8,6 +8,7 @@ module Spree
8
8
  include Spree::MemoizedData
9
9
  include Spree::Metafields
10
10
  include Spree::Metadata
11
+ include Spree::Searchable
11
12
  include Spree::Variant::Webhooks
12
13
 
13
14
  publishes_lifecycle_events
@@ -36,31 +37,43 @@ module Spree
36
37
  with_options inverse_of: :variant do
37
38
  has_many :inventory_units
38
39
  has_many :line_items
39
- has_many :stock_items, dependent: :destroy
40
+ has_many :stock_items, dependent: :destroy, autosave: true
40
41
  end
41
42
 
42
43
  has_many :orders, through: :line_items
43
44
  with_options through: :stock_items do
44
45
  has_many :stock_locations
45
46
  has_many :stock_movements
47
+ has_many :stock_reservations
46
48
  end
47
49
 
48
50
  has_many :option_value_variants, class_name: 'Spree::OptionValueVariant'
49
51
  has_many :option_values, through: :option_value_variants, dependent: :destroy, class_name: 'Spree::OptionValue'
50
52
 
51
53
  has_many :images, -> { order(:position) }, as: :viewable, dependent: :destroy, class_name: 'Spree::Asset'
54
+
55
+ has_many :variant_media, class_name: 'Spree::VariantMedia', dependent: :destroy
56
+ # Order through the asset's product-level position so a variant's gallery
57
+ # follows whatever ordering the merchant set on the product. There's no
58
+ # per-variant reordering — link/unlink only.
59
+ has_many :associated_media,
60
+ -> { order(Spree::Asset.arel_table[:position].asc) },
61
+ through: :variant_media, source: :asset, class_name: 'Spree::Asset'
62
+
52
63
  belongs_to :primary_media, class_name: 'Spree::Asset', optional: true, foreign_key: :primary_media_id
53
64
 
54
65
  has_many :prices,
55
66
  class_name: 'Spree::Price',
56
67
  dependent: :destroy,
57
- inverse_of: :variant
68
+ inverse_of: :variant,
69
+ autosave: true
58
70
 
59
71
  has_many :wished_items, dependent: :destroy
60
72
 
61
73
  has_many :digitals
62
74
 
63
75
  before_validation :set_cost_currency
76
+ before_validation :apply_pending_options, if: :pending_options?
64
77
 
65
78
  validate :check_price, if: -> { Spree::Config.enable_legacy_default_price }
66
79
 
@@ -128,11 +141,29 @@ module Spree
128
141
 
129
142
  scope :with_digital_assets, -> { joins(:digitals) }
130
143
 
131
- scope :search, ->(query) {
132
- next none if query.blank? || query.length < 3
144
+ # Free-text variant search: SKU, parent product name, and any
145
+ # option-value presentation (e.g. "Red", "XL"). The 3-char floor
146
+ # keeps single-letter queries from triggering a full scan.
147
+ def self.search(query)
148
+ return none if query.blank? || query.length < 3
133
149
 
134
- product_name_or_sku_cont(query)
135
- }
150
+ conditions = [
151
+ search_condition(self, :sku, query),
152
+ search_condition(Spree::OptionValue, :presentation, query),
153
+ ]
154
+
155
+ if Spree.use_translations?
156
+ translation_table = Product::Translation.arel_table.alias(Product.translation_table_alias)
157
+ sanitized = sanitize_query_for_search(query)
158
+ conditions << translation_table[:name].lower.matches("%#{sanitized}%", '\\')
159
+ else
160
+ conditions << search_condition(Spree::Product, :name, query)
161
+ end
162
+
163
+ relation = joins(:product).left_joins(:option_values)
164
+ relation = relation.join_translation_table(Product) if Spree.use_translations?
165
+ relation.where(conditions.reduce(:or)).distinct
166
+ end
136
167
 
137
168
  # Backward compatibility alias — remove in Spree 6.0
138
169
  scope :multi_search, ->(*args) { search(*args) }
@@ -166,8 +197,8 @@ module Spree
166
197
 
167
198
  self.whitelisted_ransackable_associations = %w[option_values product tax_category prices default_price]
168
199
  self.whitelisted_ransackable_attributes = %w[weight depth width height sku discontinue_on is_master cost_price cost_currency track_inventory
169
- deleted_at]
170
- self.whitelisted_ransackable_scopes = %i(product_name_or_sku_cont search_by_product_name_or_sku)
200
+ deleted_at product_id]
201
+ self.whitelisted_ransackable_scopes = %i(product_name_or_sku_cont search_by_product_name_or_sku search)
171
202
 
172
203
  def self.product_name_or_sku_cont(query)
173
204
  sanitized_query = ActiveRecord::Base.sanitize_sql_like(query.to_s.downcase.strip)
@@ -260,16 +291,21 @@ module Spree
260
291
  end
261
292
 
262
293
  # Returns the variant's media gallery.
263
- # Currently returns direct images. In 6.0 will use variant_media join table.
294
+ # Prefers product-level media linked via variant_media (5.5+) these reuse
295
+ # a single blob across variants. Falls back to direct variant images for
296
+ # legacy uploads.
264
297
  # @return [ActiveRecord::Relation]
265
298
  def gallery_media
299
+ return associated_media if has_associated_media?
300
+
266
301
  images
267
302
  end
268
303
 
269
- # Returns true if the variant has media.
270
- # Uses loaded association when available, otherwise falls back to counter cache.
304
+ # Returns true if the variant has media (linked product-level or direct images).
305
+ # Uses loaded associations when available, otherwise falls back to counter cache.
271
306
  # @return [Boolean]
272
307
  def has_media?
308
+ return true if has_associated_media?
273
309
  return images.any? if images.loaded?
274
310
 
275
311
  media_count.positive?
@@ -277,6 +313,13 @@ module Spree
277
313
 
278
314
  alias has_images? has_media?
279
315
 
316
+ # @return [Boolean] true if any product-level media is linked to this variant
317
+ def has_associated_media?
318
+ return variant_media.any? if variant_media.loaded?
319
+
320
+ variant_media.exists?
321
+ end
322
+
280
323
  # @deprecated Use #primary_media instead.
281
324
  def default_image
282
325
  Spree::Deprecation.warn('Spree::Variant#default_image is deprecated and will be removed in Spree 6.0. Please use Spree::Variant#primary_media instead.')
@@ -285,8 +328,10 @@ module Spree
285
328
 
286
329
  # Updates primary_media_id to the first media item by position.
287
330
  # Called when media is added, removed, or reordered.
331
+ # Uses gallery_media so product-level assets linked via VariantMedia are
332
+ # considered alongside legacy variant-pinned images.
288
333
  def update_thumbnail!
289
- first_media = images.order(:position).first
334
+ first_media = gallery_media.first
290
335
  update_column(:primary_media_id, first_media&.id)
291
336
  end
292
337
 
@@ -330,6 +375,11 @@ module Spree
330
375
  # @param options [Array<Hash>] the options to set
331
376
  # @return [void]
332
377
  def options=(options = {})
378
+ if product.nil?
379
+ @pending_options = options
380
+ return
381
+ end
382
+
333
383
  options.each do |option|
334
384
  next if option[:name].blank? || option[:value].blank?
335
385
 
@@ -438,6 +488,51 @@ module Spree
438
488
  price_in(currency).try(:compare_at_amount)
439
489
  end
440
490
 
491
+ # Syncs base prices from an array of hashes.
492
+ # Upserts prices for listed currencies, removes base prices for unlisted currencies.
493
+ # On new records, builds prices in memory (saved when variant is saved).
494
+ # On persisted records, saves prices immediately and removes unlisted currencies.
495
+ # An empty array clears every base price — distinguished from `nil` (no
496
+ # change requested), which falls through to the default ActiveRecord setter.
497
+ # @param prices_params [Array<Hash>, nil] array of { currency:, amount:, compare_at_amount: }
498
+ # @return [void]
499
+ def prices=(prices_params)
500
+ return super if prices_params.nil? || prices_params.first.is_a?(Spree::Price)
501
+
502
+ currencies_in_payload = []
503
+
504
+ prices_params.each do |price_data|
505
+ price_data = price_data.to_h.with_indifferent_access
506
+ currencies_in_payload << price_data[:currency]
507
+ set_price(price_data[:currency], price_data[:amount], price_data[:compare_at_amount])
508
+ end
509
+
510
+ # Remove base prices for currencies not in the payload (including the
511
+ # `prices_params == []` case, which clears every base price).
512
+ prices.base_prices.where.not(currency: currencies_in_payload).destroy_all if persisted?
513
+ end
514
+
515
+ # Syncs stock items from an array of hashes.
516
+ # Upserts stock for listed locations, soft-deletes stock items for unlisted locations.
517
+ # On new records, defers to after_create callback.
518
+ # @param stock_items_params [Array<Hash>] array of { stock_location_id:, count_on_hand:, backorderable: }
519
+ # @return [void]
520
+ def stock_items=(stock_items_params)
521
+ return super if stock_items_params.blank? || stock_items_params.first.is_a?(Spree::StockItem)
522
+
523
+ location_ids_in_payload = []
524
+
525
+ stock_items_params.each do |stock_data|
526
+ stock_data = stock_data.to_h.with_indifferent_access
527
+ location = Spree::StockLocation.find_by_param(stock_data[:stock_location_id])
528
+ location_ids_in_payload << location.id
529
+ set_stock(stock_data[:count_on_hand], stock_data[:backorderable], location)
530
+ end
531
+
532
+ # Soft-delete stock items for locations not in the payload
533
+ stock_items.where.not(stock_location_id: location_ids_in_payload).destroy_all if persisted?
534
+ end
535
+
441
536
  # Sets the base price (global price, not for a price list) for the given currency.
442
537
  # @param currency [String] the currency to set the price for
443
538
  # @param amount [BigDecimal] the amount to set
@@ -465,16 +560,18 @@ module Spree
465
560
  Spree::Pricing::Resolver.new(context).resolve
466
561
  end
467
562
 
468
- # Sets the stock for the variant
563
+ # Sets the stock for the variant at a given location.
564
+ # Mirrors set_price: find-or-initialize, set attrs, save only if persisted.
469
565
  # @param count_on_hand [Integer] the count on hand
470
566
  # @param backorderable [Boolean] the backorderable flag
471
- # @param stock_location [Spree::StockLocation] the stock location to set the stock for
567
+ # @param stock_location [Spree::StockLocation] the stock location (defaults to store default)
472
568
  # @return [void]
473
- def set_stock(count_on_hand, backorderable = nil)
474
- stock_item = stock_items.find_or_initialize_by(stock_location: default_stock_location)
569
+ def set_stock(count_on_hand, backorderable = nil, stock_location = nil)
570
+ stock_location ||= default_stock_location
571
+ stock_item = stock_items.find_or_initialize_by(stock_location: stock_location)
475
572
  stock_item.count_on_hand = count_on_hand
476
573
  stock_item.backorderable = backorderable if backorderable.present?
477
- stock_item.save!
574
+ stock_item.save! if persisted?
478
575
  end
479
576
 
480
577
  def default_stock_location
@@ -532,7 +629,7 @@ module Spree
532
629
  @on_sale ||= price_in(currency)&.discounted?
533
630
  end
534
631
 
535
- delegate :total_on_hand, :can_supply?, to: :quantifier
632
+ delegate :total_on_hand, :available_stock, :reserved_quantity, :can_supply?, to: :quantifier
536
633
 
537
634
  alias is_backorderable? backorderable?
538
635
 
@@ -585,6 +682,23 @@ module Spree
585
682
 
586
683
  private
587
684
 
685
+ def pending_options?
686
+ @pending_options.present?
687
+ end
688
+
689
+ def apply_pending_options
690
+ return unless @pending_options
691
+
692
+ options_to_apply = @pending_options
693
+ @pending_options = nil
694
+
695
+ options_to_apply.each do |option|
696
+ next if option[:name].blank? || option[:value].blank?
697
+
698
+ set_option_value(option[:name], option[:value], option[:position])
699
+ end
700
+ end
701
+
588
702
  def ensure_not_in_complete_orders
589
703
  if orders.complete.any?
590
704
  errors.add(:base, :cannot_destroy_if_attached_to_line_items)
@@ -0,0 +1,46 @@
1
+ module Spree
2
+ # FK column is `media_id` (not `asset_id`) to match the 6.0 rename
3
+ # Spree::Asset → Spree::Media. The `:asset` association name follows the
4
+ # current parent class; in 6.0 it renames to `:media` without a column change.
5
+ class VariantMedia < Spree.base_class
6
+ self.table_name = 'spree_variant_media'
7
+
8
+ belongs_to :variant, class_name: 'Spree::Variant', touch: true
9
+ belongs_to :asset, class_name: 'Spree::Asset', foreign_key: :media_id, inverse_of: :variant_media
10
+
11
+ validates :variant, :asset, presence: true
12
+ validates :media_id, uniqueness: { scope: :variant_id }
13
+ validate :asset_belongs_to_variant_product
14
+
15
+ after_commit :refresh_variant_thumbnail, on: %i[create destroy]
16
+
17
+ # Resolves an array of variant identifiers (prefixed ids or raw ids) to the
18
+ # numeric ids of variants that belong to `product`. Anything else — bad
19
+ # prefix, foreign product, garbage — is dropped. This is the security
20
+ # boundary used by Spree::Asset#variant_ids=, so callers (forms, API params)
21
+ # can't link assets to variants from another product.
22
+ def self.resolve_variant_ids(product, variant_ids)
23
+ ids = Array(variant_ids).reject(&:blank?)
24
+ return [] if ids.empty?
25
+
26
+ product.variants.filter_map do |variant|
27
+ token = variant.id.to_s
28
+ prefixed = variant.prefixed_id
29
+ variant.id if ids.any? { |id| id.to_s == token || id == prefixed }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def asset_belongs_to_variant_product
36
+ return if asset.blank? || variant.blank?
37
+ return if asset.product&.id == variant.product_id
38
+
39
+ errors.add(:asset, 'must belong to the same product as the variant')
40
+ end
41
+
42
+ def refresh_variant_thumbnail
43
+ variant&.update_thumbnail!
44
+ end
45
+ end
46
+ end
@@ -19,7 +19,7 @@ module Spree
19
19
  scope :for_event, ->(event_name) { where(event_name: event_name) }
20
20
 
21
21
  # Ransack configuration
22
- self.whitelisted_ransackable_attributes = %w[event_name response_code execution_time success delivered_at]
22
+ self.whitelisted_ransackable_attributes = %w[event_name response_code execution_time success delivered_at created_at]
23
23
 
24
24
  # Check if the delivery was successful
25
25
  #
@@ -22,6 +22,12 @@ module Spree
22
22
  validate :url_must_not_resolve_to_private_ip, if: -> { !Rails.env.development? && url.present? && url_changed? }
23
23
 
24
24
  before_create :generate_secret_key
25
+ after_create { @reveal_secret_in_response = true }
26
+ # Re-enabling via a direct `update(active: true)` (e.g., the dashboard's
27
+ # edit form) must also clear the auto-disable bookkeeping so the endpoint
28
+ # rejoins the `enabled` scope. `#enable!` handles this too, but we can't
29
+ # rely on every call site using it.
30
+ before_save :clear_disabled_state_when_reactivated
25
31
 
26
32
  self.whitelisted_ransackable_attributes = %w[name url active]
27
33
 
@@ -29,6 +35,17 @@ module Spree
29
35
  scope :inactive, -> { where(active: false) }
30
36
  scope :enabled, -> { active.where(disabled_at: nil) }
31
37
 
38
+ # Returns the plaintext `secret_key` only on the create response.
39
+ #
40
+ # `@reveal_secret_in_response` is set by the `after_create` callback above
41
+ # — a per-instance flag, not derived from `previous_changes`, so a reload
42
+ # or any subsequent save can't accidentally re-expose the secret.
43
+ #
44
+ # @return [String, nil]
45
+ def secret_key_for_response
46
+ @reveal_secret_in_response ? secret_key : nil
47
+ end
48
+
32
49
  # Number of consecutive failed deliveries before auto-disabling
33
50
  AUTO_DISABLE_THRESHOLD = 15
34
51
 
@@ -128,6 +145,13 @@ module Spree
128
145
  self.secret_key ||= SecureRandom.hex(32)
129
146
  end
130
147
 
148
+ def clear_disabled_state_when_reactivated
149
+ return unless will_save_change_to_active? && active
150
+
151
+ self.disabled_at = nil
152
+ self.disabled_reason = nil
153
+ end
154
+
131
155
  def url_must_not_resolve_to_private_ip
132
156
  uri = URI.parse(url)
133
157
  blacklist = SsrfFilter::IPV4_BLACKLIST + SsrfFilter::IPV6_BLACKLIST