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
@@ -56,6 +56,7 @@ module Spree
56
56
  :shipment_attributes,
57
57
  :shipping_method_attributes,
58
58
  :shipping_category_attributes,
59
+ :channel_attributes,
59
60
  :source_attributes,
60
61
  :stock_item_attributes,
61
62
  :stock_location_attributes,
@@ -90,10 +91,9 @@ module Spree
90
91
 
91
92
  @@allowed_origin_attributes = [:origin]
92
93
 
93
- @@api_key_attributes = [:name, :key_type]
94
+ @@api_key_attributes = [:name, :key_type, { scopes: [] }]
94
95
 
95
- @@asset_attributes = [:type, :viewable_id, :viewable_type, :attachment, :alt, :position,
96
- :media_type, :focal_point_x, :focal_point_y, :external_video_url]
96
+ @@asset_attributes = [:type, :viewable_id, :viewable_type, :attachment, :alt, :position, :url, :signed_id]
97
97
 
98
98
  @@checkout_attributes = [
99
99
  :coupon_code, :email, :shipping_method_id, :special_instructions, :use_billing, :use_shipping,
@@ -169,7 +169,7 @@ module Spree
169
169
 
170
170
  @@payment_attributes = [:amount, :payment_method_id, :payment_method]
171
171
 
172
- @@payment_method_attributes = [:name, :type, :description, :active, :display_on, :auto_capture, :position]
172
+ @@payment_method_attributes = [:name, :type, :description, :active, :display_on, :auto_capture, :position, { metadata: {}, preferences: {} }]
173
173
 
174
174
  @@payment_session_attributes = [:amount, :payment_method_id, { external_data: {} }]
175
175
 
@@ -192,8 +192,8 @@ module Spree
192
192
  label_list: [],
193
193
  option_type_ids: [],
194
194
  taxon_ids: [],
195
- store_ids: [],
196
- product_option_types_attributes: [:id, :option_type_id, :position, :_destroy]
195
+ product_option_types_attributes: [:id, :option_type_id, :position, :_destroy],
196
+ legacy_product_publications_attributes: [:id, :channel_id, :published_at, :unpublished_at, :_destroy]
197
197
  }
198
198
  ]
199
199
 
@@ -240,6 +240,8 @@ module Spree
240
240
 
241
241
  @@shipping_category_attributes = [:name]
242
242
 
243
+ @@channel_attributes = [:name, :code, :active, :default, :preferred_order_routing_strategy]
244
+
243
245
  @@shipping_method_attributes = [:name, :admin_name, :code, :tracking_url, :tax_category_id, :display_on,
244
246
  :estimated_transit_business_days_min, :estimated_transit_business_days_max,
245
247
  :calculator_type, :preferences, zone_ids: [], shipping_category_ids: [], calculator_attributes: {}]
@@ -251,7 +253,7 @@ module Spree
251
253
  :gateway_payment_profile_id, :last_digits, :name, :encrypted_data
252
254
  ]
253
255
 
254
- @@stock_item_attributes = [:variant_id, :stock_location_id, :backorderable, :count_on_hand]
256
+ @@stock_item_attributes = [:variant_id, :stock_location_id, :backorderable, :count_on_hand, { metadata: {} }]
255
257
 
256
258
  @@stock_location_attributes = [
257
259
  :name, :active, :address1, :address2, :city, :zipcode, :company,
@@ -5,19 +5,26 @@ FactoryBot.define do
5
5
  company { 'Company' }
6
6
  sequence(:address1) { |n| "#{n} Lovely Street" }
7
7
  address2 { 'Northwest' }
8
- city { 'Herndon' }
9
- zipcode { '35005' }
8
+ city { 'New York' }
9
+ zipcode { '10118' }
10
10
  phone { '555-555-0199' }
11
11
  alternative_phone { '555-555-0199' }
12
12
 
13
- state { |address| address.association(:state) || Spree::State.last }
14
-
15
- country do |address|
16
- if address.state
17
- address.state.country
18
- else
19
- address.association(:country)
13
+ # Default to a real US/NY pair (cached via find_or_create_by) so generated
14
+ # OpenAPI examples carry plausible country/state fields. Tests that need a
15
+ # different state/country pass them explicitly.
16
+ country do
17
+ Spree::Country.find_or_create_by!(iso: 'US') do |c|
18
+ c.iso3 = 'USA'
19
+ c.name = 'United States of America'
20
+ c.iso_name = 'UNITED STATES'
21
+ c.numcode = 840
22
+ c.states_required = true
20
23
  end
21
24
  end
25
+
26
+ state do |address|
27
+ (address.country || Spree::Country.find_by(iso: 'US'))&.states&.find_or_create_by!(abbr: 'NY') { |s| s.name = 'New York' }
28
+ end
22
29
  end
23
30
  end
@@ -10,6 +10,7 @@ FactoryBot.define do
10
10
 
11
11
  trait :secret do
12
12
  key_type { 'secret' }
13
+ scopes { ['write_all'] }
13
14
  end
14
15
 
15
16
  trait :revoked do
@@ -0,0 +1,8 @@
1
+ FactoryBot.define do
2
+ factory :channel, class: Spree::Channel do
3
+ store { Spree::Store.default || association(:store) }
4
+ sequence(:name) { |n| "Channel #{n}" }
5
+ sequence(:code) { |n| "channel_#{n}" }
6
+ active { true }
7
+ end
8
+ end
@@ -8,14 +8,8 @@ FactoryBot.define do
8
8
  product { nil }
9
9
  end
10
10
  variant do
11
- resolved_product = product || begin
12
- if order&.store&.present?
13
- create(:product, stores: [order.store])
14
- else
15
- create(:product)
16
- end
17
- end
18
- resolved_product.master
11
+ resolved_product = product || create(:product)
12
+ resolved_product.default_variant
19
13
  end
20
14
  end
21
15
  end
@@ -3,6 +3,8 @@ FactoryBot.define do
3
3
  email { FFaker::Internet.unique.email }
4
4
  verified_at { nil }
5
5
 
6
+ store { Spree::Current.store || create(:store) }
7
+
6
8
  trait :with_user do
7
9
  association :user, factory: :user
8
10
  end
@@ -4,12 +4,10 @@ FactoryBot.define do
4
4
  description { generate(:random_description) }
5
5
  cost_price { 17.00 }
6
6
  sku { generate(:sku) }
7
- available_on { 1.year.ago }
8
- make_active_at { 1.year.ago }
9
7
  deleted_at { nil }
10
8
  shipping_category { |r| Spree::ShippingCategory.first || r.association(:shipping_category) }
11
9
  status { 'active' }
12
- stores { [Spree::Store.default] }
10
+ store { Spree::Store.default || association(:store) }
13
11
 
14
12
  transient do
15
13
  price { 19.99 }
@@ -17,11 +15,8 @@ FactoryBot.define do
17
15
  currency { nil }
18
16
  end
19
17
 
20
- # ensure stock item will be created for this products master
21
- # also attach this product to the default store if no stores are passed in
22
18
  before(:create) do |_product|
23
19
  create(:stock_location) unless Spree::StockLocation.any?
24
- create(:store, default: true) unless Spree::Store.any?
25
20
  end
26
21
  after(:create) do |product, evaluator|
27
22
  existing_location_ids = product.master.stock_items.pluck(:stock_location_id)
@@ -30,9 +25,23 @@ FactoryBot.define do
30
25
  end
31
26
 
32
27
  if evaluator.price.present?
33
- price_currency = evaluator.currency || product.stores.first&.default_currency || 'USD'
28
+ price_currency = evaluator.currency || product.store&.default_currency || 'USD'
34
29
  product.master.set_price(price_currency, evaluator.price, evaluator.compare_at_price)
35
30
  end
31
+
32
+ # Test convenience only: auto-publish each product on its store's
33
+ # default channel so legacy spec assertions that depend on
34
+ # current-channel visibility (.active, .available, .not_discontinued)
35
+ # keep passing. Production callers must publish explicitly via the
36
+ # Admin SDK / Dashboard create form.
37
+ if product.store&.default_channel && product.product_publications.empty?
38
+ Spree::ProductPublication.create!(
39
+ product: product,
40
+ channel: product.store.default_channel,
41
+ published_at: product.available_on,
42
+ unpublished_at: product.discontinue_on
43
+ )
44
+ end
36
45
  end
37
46
 
38
47
  factory :custom_product do
@@ -0,0 +1,6 @@
1
+ FactoryBot.define do
2
+ factory :product_publication, class: Spree::ProductPublication do
3
+ product
4
+ channel { product&.store&.default_channel || association(:channel) }
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ FactoryBot.define do
2
+ factory :refresh_token, class: 'Spree::RefreshToken' do
3
+ association :user, factory: :user
4
+ user_type { user.class.to_s }
5
+ expires_at { Spree::RefreshToken.default_expiry.from_now }
6
+
7
+ trait :for_admin do
8
+ association :user, factory: :admin_user
9
+ end
10
+
11
+ trait :expired do
12
+ expires_at { 1.minute.ago }
13
+ end
14
+ end
15
+ end
@@ -19,8 +19,8 @@ FactoryBot.define do
19
19
  # variant will add itself to all stock_locations in an after_create
20
20
  # creating a product will automatically create a master variant
21
21
  store = Spree::Store.first || create(:store)
22
- product_1 = create(:product, stores: [store])
23
- product_2 = create(:product, stores: [store])
22
+ product_1 = create(:product)
23
+ product_2 = create(:product)
24
24
 
25
25
  stock_location.stock_item_or_create(product_1.master).adjust_count_on_hand(10)
26
26
  stock_location.stock_item_or_create(product_2.master).adjust_count_on_hand(20)
@@ -0,0 +1,31 @@
1
+ FactoryBot.define do
2
+ factory :stock_reservation, class: Spree::StockReservation do
3
+ quantity { 1 }
4
+ expires_at { 10.minutes.from_now }
5
+
6
+ transient do
7
+ order { nil }
8
+ end
9
+
10
+ # Build the order first (with at least one line_item), then derive
11
+ # stock_item from that line_item's variant so the three FKs reference the
12
+ # same variant. Callers can override stock_item:/line_item:/order: to wire
13
+ # up a specific scenario.
14
+ after(:build) do |reservation, evaluator|
15
+ reservation.order ||= evaluator.order || create(:order_with_line_items, line_items_count: 1)
16
+
17
+ if reservation.line_item.nil?
18
+ reservation.line_item = reservation.order.line_items.first ||
19
+ create(:line_item, order: reservation.order)
20
+ reservation.order.line_items.reload
21
+ end
22
+
23
+ reservation.stock_item ||= reservation.line_item.variant.stock_items.first ||
24
+ create(:stock_item, variant: reservation.line_item.variant)
25
+ end
26
+
27
+ trait :expired do
28
+ expires_at { 1.minute.ago }
29
+ end
30
+ end
31
+ end
@@ -11,7 +11,7 @@ FactoryBot.define do
11
11
  is_master { 0 }
12
12
  track_inventory { true }
13
13
 
14
- product { |p| p.association(:base_product, stores: [Spree::Store.default]) }
14
+ product { |p| p.association(:base_product) }
15
15
  option_values { [build(:option_value)] }
16
16
 
17
17
  transient do
@@ -35,14 +35,14 @@ FactoryBot.define do
35
35
  end
36
36
 
37
37
  if evaluator.price.present?
38
- price_currency = evaluator.currency || variant.product&.stores&.first&.default_currency || 'USD'
38
+ price_currency = evaluator.currency || variant.product&.store&.default_currency || 'USD'
39
39
  variant.set_price(price_currency, evaluator.price, evaluator.compare_at_price)
40
40
  end
41
41
  end
42
42
 
43
43
  factory :variant do
44
44
  # on_hand 5
45
- product { |p| p.association(:product, stores: [Spree::Store.default]) }
45
+ product { |p| p.association(:product) }
46
46
 
47
47
  factory :with_image_variant do
48
48
  images { create_list(:image, 1) }
@@ -4,7 +4,7 @@ class OrderWalkthrough
4
4
 
5
5
  # A payment method must exist for an order to proceed through the Address state
6
6
  unless Spree::PaymentMethod.exists?
7
- FactoryBot.create(:check_payment_method, stores: [store])
7
+ FactoryBot.create(:check_payment_method)
8
8
  end
9
9
 
10
10
  # Need to create a valid zone too...
@@ -31,6 +31,16 @@ RSpec.configure do |config|
31
31
  @default_store&.promotions = []
32
32
  @default_store&.update_column(:checkout_zone_id, nil) if @default_store&.read_attribute(:checkout_zone_id).present?
33
33
  @default_store&.payment_methods = []
34
+ # The shared +@default_store+ Ruby object lives across the whole
35
+ # +before(:all)+ block, so AR association caches (+default_market+,
36
+ # +channels+, etc.) and per-instance memos (+@has_markets+) need to
37
+ # be cleared between examples or stale +nil+s leak across tests.
38
+ if @default_store
39
+ @default_store.association(:default_market).reset if @default_store.association_cached?(:default_market)
40
+ @default_store.association(:markets).reset if @default_store.association_cached?(:markets)
41
+ @default_store.remove_instance_variable(:@has_markets) if @default_store.instance_variable_defined?(:@has_markets)
42
+ @default_store.reload
43
+ end
34
44
  end
35
45
  end
36
46
 
@@ -0,0 +1,53 @@
1
+ # Spree 5.4 → 5.5 upgrade manifest.
2
+ #
3
+ # Lists ONLY the version-specific rake tasks that perform data backfills.
4
+ # Universal upgrade steps (bundle update, db:migrate, scheduling cron jobs,
5
+ # reviewing breaking changes) are NOT in this file:
6
+ #
7
+ # - `bundle update` + `db:migrate` are handled by your deploy pipeline
8
+ # (Heroku release phase, K8s init container, Render auto-migrate,
9
+ # Capistrano deploy hook, etc.) and by the @spree/cli's `spree upgrade`
10
+ # wrapper for local development.
11
+ # - Cron scheduling, optional tunings, and human-readable behavior changes
12
+ # live in the upgrade doc at docs/developer/upgrades/5.4-to-5.5.mdx.
13
+ #
14
+ # This manifest is the machine-runnable shape — what `bin/rake spree:upgrade`
15
+ # (in production) and `spree upgrade` (in dev) execute. Every step must be a
16
+ # rake task and must be idempotent. Re-running the full manifest is safe.
17
+ ---
18
+ from: "5.4"
19
+ to: "5.5"
20
+ docs: "https://spreecommerce.org/docs/developer/upgrades/5.4-to-5.5"
21
+
22
+ steps:
23
+ - id: media
24
+ name: "Migrate legacy variant-pinned images to product-level media"
25
+ task: "spree:media:migrate_master_images_to_product_media"
26
+ notes: |
27
+ Enqueues one `Spree::Media::MigrateProductAssetsJob` per product onto
28
+ the `images` queue — confirm your job runner is processing that queue.
29
+ Storefront keeps working while jobs drain (old assets stay variant-pinned
30
+ until each job finishes). For large catalogs, tune with BATCH_SIZE=1000.
31
+
32
+ - id: channels
33
+ name: "Run the Channels upgrade (creates default channels, publications, order channel ids)"
34
+ task: "spree:channels:upgrade"
35
+ notes: |
36
+ Aggregator that runs four sub-tasks in order:
37
+ 1. spree:channels:create_defaults
38
+ 2. spree:upgrade:populate_publications
39
+ 3. spree:channels:backfill_order_channel_ids
40
+ 4. spree:channels:backfill_product_publication_dates
41
+ Until this runs, every product has store_id IS NULL and is invisible
42
+ to Product.for_store — admin lists, storefront catalog, and search
43
+ indexer all return empty.
44
+
45
+ - id: reindex
46
+ name: "Reindex products against the configured search provider"
47
+ task: "spree:search:reindex"
48
+ notes: |
49
+ No-op for the default Database provider. For Meilisearch (or any other
50
+ external search provider), this is required after the channels upgrade
51
+ because products only become visible to Product.for_store once they
52
+ have a store_id — reindexing before the channels step would index 0
53
+ products. Must run AFTER `channels`.
@@ -0,0 +1,94 @@
1
+ namespace :spree do
2
+ namespace :channels do
3
+ desc 'Create the default channel for every existing store (idempotent — calls Store#ensure_default_channel).'
4
+ task create_defaults: :environment do
5
+ created = 0
6
+ Spree::Store.find_each do |store|
7
+ next if store.default_channel
8
+
9
+ store.ensure_default_channel
10
+ created += 1
11
+ puts " Created default channel for store '#{store.name}'"
12
+ end
13
+
14
+ puts created.zero? ? ' All stores already have a default channel.' : " Created #{created} default channel(s)."
15
+ end
16
+
17
+ desc 'Backfill spree_orders.channel_id from the legacy spree_orders.channel string column'
18
+ task backfill_order_channel_ids: :environment do
19
+ # Idempotent: only touches orders where channel_id is nil. Safe to
20
+ # re-run after partial completion. Returns gracefully if the legacy
21
+ # string column has already been dropped.
22
+ unless legacy_channel_column?
23
+ puts 'Legacy channel column not present — backfill is unnecessary.'
24
+ next
25
+ end
26
+
27
+ Spree::Store.find_each do |store|
28
+ legacy_codes = Spree::Order.where(store_id: store.id, channel_id: nil)
29
+ .distinct
30
+ .pluck(Arel.sql('channel'))
31
+ .compact_blank
32
+
33
+ codes_to_process = legacy_codes.uniq
34
+ codes_to_process << Spree::Channel::DEFAULT_CODE unless codes_to_process.include?(Spree::Channel::DEFAULT_CODE)
35
+
36
+ codes_to_process.each do |code|
37
+ channel = store.channels.find_or_create_by!(code: code) do |c|
38
+ c.name = code.titleize
39
+ end
40
+
41
+ scope = Spree::Order.where(store_id: store.id, channel_id: nil)
42
+ scope = if code == Spree::Channel::DEFAULT_CODE
43
+ # Only the default channel claims NULL/blank rows.
44
+ scope.where(Arel.sql("channel = ? OR channel IS NULL OR channel = ''"), code)
45
+ else
46
+ scope.where(Arel.sql('channel = ?'), code)
47
+ end
48
+
49
+ updated = scope.update_all(channel_id: channel.id)
50
+
51
+ next if updated.zero?
52
+
53
+ puts " Store '#{store.name}': mapped #{updated} orders with channel='#{code}' → #{channel.name} (#{channel.code})"
54
+ end
55
+ end
56
+ end
57
+
58
+ desc 'Backfill published_at and unpublished_at on ProductPublications from the legacy Product.available_on / discontinue_on columns'
59
+ task backfill_product_publication_dates: :environment do
60
+ # Per-product loop (not join-update) for SQLite/MySQL/Postgres portability.
61
+ published = 0
62
+ unpublished = 0
63
+
64
+ products_with_dates = Spree::Product.where.not(available_on: nil).or(Spree::Product.where.not(discontinue_on: nil))
65
+
66
+ products_with_dates.find_each(batch_size: 500) do |product|
67
+ publications = Spree::ProductPublication.where(product_id: product.id)
68
+ # Read raw columns — +product.available_on+ / +product.discontinue_on+
69
+ # go through +Product::Channels+'s reader override which prefers the
70
+ # current-channel publication's date (which is nil pre-backfill).
71
+ legacy_available_on = product[:available_on]
72
+ legacy_discontinue_on = product[:discontinue_on]
73
+
74
+ published += publications.where(published_at: nil).update_all(published_at: legacy_available_on) if legacy_available_on
75
+ unpublished += publications.where(unpublished_at: nil).update_all(unpublished_at: legacy_discontinue_on) if legacy_discontinue_on
76
+ end
77
+
78
+ total = published + unpublished
79
+ puts total.zero? ? ' All product-publication dates already populated.' : " Backfilled dates on #{published} published_at + #{unpublished} unpublished_at column(s)."
80
+ end
81
+
82
+ desc 'Run the full 5.4 → 5.5 channel upgrade: create default channels, backfill products to store_id and publications, backfill order channels, backfill publication date windows'
83
+ task upgrade: [
84
+ :create_defaults,
85
+ 'spree:upgrade:populate_publications',
86
+ :backfill_order_channel_ids,
87
+ :backfill_product_publication_dates
88
+ ]
89
+
90
+ def legacy_channel_column?
91
+ ActiveRecord::Base.connection.column_exists?(:spree_orders, :channel)
92
+ end
93
+ end
94
+ end
data/lib/tasks/core.rake CHANGED
@@ -43,6 +43,7 @@ namespace :core do
43
43
  user_id: id,
44
44
  verified_at: updated_at,
45
45
  verification_token: nil,
46
+ store_id: Spree::Store.default&.id,
46
47
  updated_at: DateTime.current,
47
48
  created_at: DateTime.current
48
49
  }
data/lib/tasks/media.rake CHANGED
@@ -16,5 +16,32 @@ namespace :spree do
16
16
 
17
17
  puts 'Done!'
18
18
  end
19
+
20
+ # Enqueues Spree::Media::MigrateProductAssetsJob for every product that
21
+ # still has at least one variant-pinned asset. The job is idempotent, so
22
+ # re-running this task is safe.
23
+ #
24
+ # ENV vars:
25
+ # BATCH_SIZE — products fetched per scope batch (default: 500)
26
+ desc 'Enqueue jobs to migrate legacy variant-pinned images to product-level media (opt-in, 5.5)'
27
+ task migrate_master_images_to_product_media: :environment do
28
+ batch_size = ENV.fetch('BATCH_SIZE', 500).to_i
29
+ batch_size = 500 if batch_size < 1
30
+
31
+ # Subquery (not pluck) so the product set doesn't materialize in Ruby —
32
+ # important for catalogs with millions of products.
33
+ variant_product_ids = Spree::Variant
34
+ .joins("INNER JOIN #{Spree::Asset.table_name} ON " \
35
+ "#{Spree::Asset.table_name}.viewable_id = #{Spree::Variant.table_name}.id " \
36
+ "AND #{Spree::Asset.table_name}.viewable_type = 'Spree::Variant'")
37
+ .select(:product_id)
38
+
39
+ relation = Spree::Product.where(id: variant_product_ids)
40
+ relation.find_each(batch_size: batch_size) do |product|
41
+ Spree::Media::MigrateProductAssetsJob.perform_later(product.id)
42
+ end
43
+
44
+ puts "Enqueued migration jobs for #{relation.count} products on the #{Spree.queues.images} queue."
45
+ end
19
46
  end
20
47
  end
@@ -19,21 +19,19 @@ namespace :spree do
19
19
  puts "\nDone!"
20
20
  end
21
21
 
22
- desc 'Enqueue background jobs to populate product metrics for all store products'
22
+ desc 'Enqueue background jobs to populate product metrics for every product'
23
23
  task populate_metrics: :environment do
24
24
  total_count = 0
25
25
 
26
- Spree::StoreProduct.in_batches(of: 100) do |batch|
27
- jobs = batch.pluck(:product_id, :store_id).map do |product_id, store_id|
28
- Spree::Products::RefreshMetricsJob.new(product_id, store_id)
29
- end
26
+ Spree::Product.in_batches(of: 100) do |batch|
27
+ jobs = batch.pluck(:id).map { |product_id| Spree::Products::RefreshMetricsJob.new(product_id) }
30
28
  ActiveJob.perform_all_later(jobs)
31
29
  total_count += jobs.size
32
30
  print '.'
33
31
  end
34
32
 
35
33
  if total_count.zero?
36
- puts 'No store products found.'
34
+ puts 'No products found.'
37
35
  else
38
36
  puts "\nEnqueued #{total_count} jobs to refresh product metrics."
39
37
  end
@@ -0,0 +1,60 @@
1
+ namespace :spree do
2
+ namespace :upgrade do
3
+ desc <<~DESC
4
+ Populates +spree_products.store_id+ and +spree_product_publications+ from the legacy
5
+ +spree_products_stores+ join. Idempotent — re-running skips products that
6
+ already have a +store_id+ and channels that already have a publication for
7
+ the product.
8
+
9
+ Run once after upgrading to Spree 5.5+. Multi-store merchants must install
10
+ +spree_multi_store+ before running; running on a multi-store catalog without
11
+ the extension picks the earliest +spree_products_stores+ row (by
12
+ +created_at+) as the product's home store.
13
+ DESC
14
+ task populate_publications: :environment do
15
+ unless ActiveRecord::Base.connection.table_exists?(Spree::StoreProduct.table_name)
16
+ puts " #{Spree::StoreProduct.table_name} table not found — nothing to migrate."
17
+ next
18
+ end
19
+
20
+ batch_size = (ENV['BATCH_SIZE'] || 1_000).to_i
21
+ publications_created = 0
22
+
23
+ # Pass 1: per store, batch-publish products onto the store's default
24
+ # channel via +Channel#add_products+. One upsert + one touch_all per
25
+ # batch beats the previous per-product loop by orders of magnitude on
26
+ # large catalogs. +add_products+ is upsert-based with +on_duplicate:
27
+ # :skip+, so existing publications on re-run are no-ops.
28
+ Spree::Store.find_each do |store|
29
+ channel = store.default_channel
30
+ unless channel
31
+ puts " Store '#{store.name}' has no default channel — skipping."
32
+ next
33
+ end
34
+
35
+ store_publications = 0
36
+ Spree::StoreProduct.where(store_id: store.id).in_batches(of: batch_size) do |batch|
37
+ store_publications += channel.add_products(batch.pluck(:product_id))
38
+ end
39
+
40
+ publications_created += store_publications
41
+ puts " Store '#{store.name}': created #{store_publications} publication(s)" if store_publications.positive?
42
+ end
43
+
44
+ # Pass 2: assign +store_id+ on products that still don't have one,
45
+ # using the earliest legacy row per product.
46
+ products_processed = 0
47
+
48
+ Spree::Product.where(store_id: nil).find_each(batch_size: batch_size) do |product|
49
+ store_id = Spree::StoreProduct.where(product_id: product.id).order(:created_at).limit(1).pick(:store_id)
50
+ next unless store_id
51
+
52
+ product.update_column(:store_id, store_id)
53
+ products_processed += 1
54
+ end
55
+
56
+ puts " Processed #{products_processed} products"
57
+ puts " Created #{publications_created} publications"
58
+ end
59
+ end
60
+ end