spree_core 5.4.2 → 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 (248) 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 +3 -2
  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/publishable.rb +1 -1
  28. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  29. data/app/models/concerns/spree/search_indexable.rb +8 -7
  30. data/app/models/concerns/spree/searchable.rb +11 -2
  31. data/app/models/concerns/spree/stores/channels.rb +20 -0
  32. data/app/models/concerns/spree/stores/markets.rb +21 -5
  33. data/app/models/concerns/spree/typed_associations.rb +120 -0
  34. data/app/models/concerns/spree/user_methods.rb +71 -12
  35. data/app/models/spree/ability.rb +4 -117
  36. data/app/models/spree/api_key.rb +53 -0
  37. data/app/models/spree/asset.rb +37 -14
  38. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  39. data/app/models/spree/base.rb +18 -1
  40. data/app/models/spree/channel.rb +159 -0
  41. data/app/models/spree/country.rb +2 -0
  42. data/app/models/spree/current.rb +5 -1
  43. data/app/models/spree/custom_field.rb +9 -0
  44. data/app/models/spree/custom_field_definition.rb +7 -0
  45. data/app/models/spree/customer_group.rb +8 -2
  46. data/app/models/spree/event.rb +6 -6
  47. data/app/models/spree/export.rb +32 -5
  48. data/app/models/spree/exports/product_translations.rb +1 -1
  49. data/app/models/spree/gateway/bogus.rb +6 -1
  50. data/app/models/spree/gateway.rb +25 -0
  51. data/app/models/spree/gift_card.rb +1 -1
  52. data/app/models/spree/gift_card_batch.rb +4 -1
  53. data/app/models/spree/import.rb +5 -0
  54. data/app/models/spree/import_row.rb +12 -0
  55. data/app/models/spree/line_item.rb +7 -2
  56. data/app/models/spree/market.rb +57 -1
  57. data/app/models/spree/metafield.rb +38 -0
  58. data/app/models/spree/metafield_definition.rb +29 -6
  59. data/app/models/spree/metafields/json.rb +10 -0
  60. data/app/models/spree/newsletter_subscriber.rb +19 -3
  61. data/app/models/spree/option_type.rb +48 -7
  62. data/app/models/spree/order/checkout.rb +3 -3
  63. data/app/models/spree/order.rb +102 -6
  64. data/app/models/spree/order_approval.rb +19 -0
  65. data/app/models/spree/order_cancellation.rb +19 -0
  66. data/app/models/spree/order_inventory.rb +24 -2
  67. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  68. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  69. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  70. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  71. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  72. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  73. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  74. data/app/models/spree/order_routing/strategy/rules.rb +81 -0
  75. data/app/models/spree/order_routing_rule.rb +75 -0
  76. data/app/models/spree/payment_setup_sessions/bogus.rb +4 -0
  77. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  78. data/app/models/spree/permission_sets/product_display.rb +2 -0
  79. data/app/models/spree/permission_sets/product_management.rb +2 -0
  80. data/app/models/spree/price.rb +14 -1
  81. data/app/models/spree/price_list.rb +129 -17
  82. data/app/models/spree/price_rule.rb +11 -1
  83. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  84. data/app/models/spree/price_rules/market_rule.rb +16 -1
  85. data/app/models/spree/price_rules/user_rule.rb +21 -2
  86. data/app/models/spree/product/channels.rb +149 -0
  87. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  88. data/app/models/spree/product/slugs.rb +1 -1
  89. data/app/models/spree/product.rb +172 -31
  90. data/app/models/spree/product_publication.rb +43 -0
  91. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  92. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  93. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  94. data/app/models/spree/promotion/rules/country.rb +40 -18
  95. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  96. data/app/models/spree/promotion/rules/product.rb +4 -0
  97. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  98. data/app/models/spree/promotion/rules/user.rb +21 -0
  99. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  100. data/app/models/spree/promotion.rb +22 -1
  101. data/app/models/spree/promotion_action.rb +17 -11
  102. data/app/models/spree/promotion_rule.rb +17 -18
  103. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  104. data/app/models/spree/shipment.rb +10 -4
  105. data/app/models/spree/stock/availability_validator.rb +1 -1
  106. data/app/models/spree/stock/quantifier.rb +89 -9
  107. data/app/models/spree/stock_item.rb +36 -0
  108. data/app/models/spree/stock_location.rb +52 -0
  109. data/app/models/spree/stock_reservation.rb +38 -0
  110. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  111. data/app/models/spree/store.rb +18 -72
  112. data/app/models/spree/store_credit.rb +0 -8
  113. data/app/models/spree/store_product.rb +11 -23
  114. data/app/models/spree/subscriber.rb +12 -12
  115. data/app/models/spree/taxon.rb +0 -5
  116. data/app/models/spree/user_identity.rb +1 -2
  117. data/app/models/spree/variant.rb +132 -18
  118. data/app/models/spree/variant_media.rb +46 -0
  119. data/app/models/spree/webhook_delivery.rb +1 -1
  120. data/app/models/spree/webhook_endpoint.rb +24 -0
  121. data/app/models/spree/wished_item.rb +0 -13
  122. data/app/presenters/spree/csv/formula_sanitizer.rb +28 -0
  123. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  124. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  125. data/app/presenters/spree/variant_presenter.rb +4 -3
  126. data/app/services/spree/addresses/update.rb +6 -8
  127. data/app/services/spree/cart/add_item.rb +10 -0
  128. data/app/services/spree/cart/empty.rb +2 -0
  129. data/app/services/spree/cart/remove_line_item.rb +10 -0
  130. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  131. data/app/services/spree/cart/set_quantity.rb +10 -0
  132. data/app/services/spree/carts/complete.rb +1 -0
  133. data/app/services/spree/carts/create.rb +1 -0
  134. data/app/services/spree/carts/update.rb +18 -2
  135. data/app/services/spree/carts/upsert_items.rb +6 -6
  136. data/app/services/spree/credit_cards/destroy.rb +1 -1
  137. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  138. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  139. data/app/services/spree/newsletter/link_user.rb +53 -0
  140. data/app/services/spree/newsletter/subscribe.rb +31 -9
  141. data/app/services/spree/orders/approve.rb +27 -6
  142. data/app/services/spree/orders/build_shipments.rb +29 -0
  143. data/app/services/spree/orders/cancel.rb +34 -3
  144. data/app/services/spree/orders/complete.rb +53 -0
  145. data/app/services/spree/orders/create.rb +156 -0
  146. data/app/services/spree/orders/update.rb +51 -0
  147. data/app/services/spree/orders/upsert_items.rb +70 -0
  148. data/app/services/spree/payments/handle_webhook.rb +3 -10
  149. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  150. data/app/services/spree/products/duplicator.rb +1 -1
  151. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  152. data/app/services/spree/sample_data/loader.rb +30 -0
  153. data/app/services/spree/stock_reservations/extend.rb +19 -0
  154. data/app/services/spree/stock_reservations/release.rb +12 -0
  155. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  156. data/app/services/spree/taxons/remove_products.rb +7 -1
  157. data/app/subscribers/spree/event_log_subscriber.rb +1 -1
  158. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  159. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  160. data/config/locales/en.yml +35 -10
  161. data/config/routes.rb +9 -0
  162. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  163. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  164. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  165. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  166. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  167. data/db/migrate/20260504103113_add_type_to_spree_payment_setup_sessions.rb +6 -0
  168. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  169. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  170. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  171. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  172. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  173. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  174. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  175. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  176. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  177. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  178. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  179. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  180. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  181. data/db/sample_data/channels.rb +12 -0
  182. data/db/sample_data/orders.rb +1 -1
  183. data/db/sample_data/products.csv +212 -212
  184. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  185. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  186. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  187. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  188. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  189. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  190. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  191. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  192. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
  193. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  194. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  195. data/lib/generators/spree/model/model_generator.rb +73 -7
  196. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  197. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  198. data/lib/spree/core/configuration.rb +7 -0
  199. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  200. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  201. data/lib/spree/core/controller_helpers/order.rb +0 -19
  202. data/lib/spree/core/dependencies.rb +5 -2
  203. data/lib/spree/core/engine.rb +54 -7
  204. data/lib/spree/core/permission_configuration.rb +15 -0
  205. data/lib/spree/core/preferences/masking.rb +47 -0
  206. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  207. data/lib/spree/core/version.rb +1 -1
  208. data/lib/spree/core.rb +56 -5
  209. data/lib/spree/events/adapters/active_support_notifications.rb +1 -1
  210. data/lib/spree/events/adapters/base.rb +3 -3
  211. data/lib/spree/events/registry.rb +1 -1
  212. data/lib/spree/events.rb +1 -1
  213. data/lib/spree/permitted_attributes.rb +9 -7
  214. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  215. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  216. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  217. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  218. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  219. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  220. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  221. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  222. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  223. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  224. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  225. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  226. data/lib/spree/testing_support/store.rb +10 -0
  227. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  228. data/lib/tasks/channels.rake +94 -0
  229. data/lib/tasks/core.rake +1 -0
  230. data/lib/tasks/media.rake +27 -0
  231. data/lib/tasks/products.rake +4 -6
  232. data/lib/tasks/publications.rake +60 -0
  233. data/lib/tasks/upgrade.rake +211 -0
  234. metadata +86 -18
  235. data/app/finders/spree/variants/visible_finder.rb +0 -23
  236. data/app/paginators/spree/shared/paginate.rb +0 -30
  237. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  238. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  239. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  240. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  241. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  242. data/app/services/spree/classifications/reposition.rb +0 -23
  243. data/app/sorters/spree/orders/sort.rb +0 -10
  244. data/lib/spree/core/controller_helpers/common.rb +0 -14
  245. data/lib/spree/core/token_generator.rb +0 -23
  246. data/lib/spree/database_type_utilities.rb +0 -22
  247. data/lib/spree/testing_support/bar_ability.rb +0 -14
  248. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -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
@@ -9,7 +9,7 @@ module Spree
9
9
  #
10
10
  # @example Basic subscriber
11
11
  # class OrderCompletedNotifier < Spree::Subscriber
12
- # subscribes_to 'order.complete'
12
+ # subscribes_to 'order.completed'
13
13
  #
14
14
  # def call(event)
15
15
  # order_id = event.payload['id']
@@ -19,7 +19,7 @@ module Spree
19
19
  #
20
20
  # @example Multi-event subscriber
21
21
  # class OrderAuditLogger < Spree::Subscriber
22
- # subscribes_to 'order.complete', 'order.cancel', 'order.resume'
22
+ # subscribes_to 'order.completed', 'order.canceled', 'order.resumed'
23
23
  #
24
24
  # def call(event)
25
25
  # AuditLog.create!(
@@ -41,11 +41,11 @@ module Spree
41
41
  #
42
42
  # @example Subscriber with method routing
43
43
  # class PaymentSubscriber < Spree::Subscriber
44
- # subscribes_to 'payment.complete', 'payment.void', 'payment.refund'
44
+ # subscribes_to 'payment.completed', 'payment.voided', 'refund.created'
45
45
  #
46
- # on 'payment.complete', :handle_complete
47
- # on 'payment.void', :handle_void
48
- # on 'payment.refund', :handle_refund
46
+ # on 'payment.completed', :handle_complete
47
+ # on 'payment.voided', :handle_void
48
+ # on 'refund.created', :handle_refund
49
49
  #
50
50
  # private
51
51
  #
@@ -64,7 +64,7 @@ module Spree
64
64
  #
65
65
  # @example Synchronous subscriber (runs immediately, not via ActiveJob)
66
66
  # class CriticalOrderHandler < Spree::Subscriber
67
- # subscribes_to 'order.complete', async: false
67
+ # subscribes_to 'order.completed', async: false
68
68
  #
69
69
  # def call(event)
70
70
  # # This runs synchronously
@@ -81,10 +81,10 @@ module Spree
81
81
  # @return [void]
82
82
  #
83
83
  # @example
84
- # subscribes_to 'order.complete'
85
- # subscribes_to 'order.complete', 'order.cancel'
84
+ # subscribes_to 'order.completed'
85
+ # subscribes_to 'order.completed', 'order.canceled'
86
86
  # subscribes_to 'order.*'
87
- # subscribes_to 'order.complete', async: false
87
+ # subscribes_to 'order.completed', async: false
88
88
  #
89
89
  def subscribes_to(*patterns, **options)
90
90
  @subscription_patterns ||= []
@@ -102,8 +102,8 @@ module Spree
102
102
  # @return [void]
103
103
  #
104
104
  # @example
105
- # on 'payment.complete', :handle_complete
106
- # on 'payment.void', :handle_void
105
+ # on 'payment.completed', :handle_complete
106
+ # on 'payment.voided', :handle_void
107
107
  #
108
108
  def on(pattern, method_name)
109
109
  @event_handlers ||= {}
@@ -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
@@ -16,19 +16,6 @@ module Spree
16
16
  validates :variant, uniqueness: { scope: [:wishlist] }
17
17
  validates :quantity, numericality: { only_integer: true, greater_than: 0 }
18
18
 
19
- # This is a workaround to allow the variant_id to be set with a prefixed ID
20
- # in the API.
21
- #
22
- # @param id [String] the prefixed ID of the variant
23
- def variant_id=(id)
24
- if id.to_s.include?('_')
25
- decoded = Spree::Variant.decode_prefixed_id(id)
26
- super(decoded)
27
- else
28
- super(id)
29
- end
30
- end
31
-
32
19
  def price(currency)
33
20
  variant.amount_in(currency[:currency])
34
21
  end
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module CSV
3
+ # Neutralizes CSV formula injection (CWE-1236 / OWASP "CSV Injection")
4
+ # by prefixing cells that would otherwise be evaluated as a formula
5
+ # when the exported file is opened in Excel, Google Sheets, LibreOffice,
6
+ # or Numbers.
7
+ #
8
+ # The leading apostrophe is the OWASP-recommended marker — spreadsheets
9
+ # render the cell as plain text without displaying the apostrophe.
10
+ module FormulaSanitizer
11
+ TRIGGERS = ["=", "+", "-", "@", "\t", "\r", "\n"].freeze
12
+
13
+ module_function
14
+
15
+ def cell(value)
16
+ return value unless value.is_a?(String)
17
+ return value if value.empty?
18
+ return value unless TRIGGERS.include?(value[0])
19
+
20
+ "'#{value}"
21
+ end
22
+
23
+ def row(values)
24
+ values.map { |v| cell(v) }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -53,7 +53,7 @@ module Spree
53
53
  @index = index
54
54
  @properties = properties
55
55
  @taxons = taxons
56
- @store = store || product.stores.first
56
+ @store = store || product.store
57
57
  @currency = currency || @store.default_currency
58
58
  @price_only = @currency != @store.default_currency
59
59
  @metafields = metafields
@@ -100,8 +100,8 @@ module Spree
100
100
  variant.dimensions_unit,
101
101
  variant.weight,
102
102
  variant.weight_unit,
103
- variant.available_on&.strftime('%Y-%m-%d %H:%M:%S'),
104
- (variant.discontinue_on || product.discontinue_on)&.strftime('%Y-%m-%d %H:%M:%S'),
103
+ publication_available_on&.strftime('%Y-%m-%d %H:%M:%S'),
104
+ (variant.discontinue_on || publication_discontinue_on)&.strftime('%Y-%m-%d %H:%M:%S'),
105
105
  variant.track_inventory?,
106
106
  total_on_hand == BigDecimal::INFINITY ? '∞' : total_on_hand,
107
107
  variant.backorderable?,
@@ -137,6 +137,26 @@ module Spree
137
137
 
138
138
  private
139
139
 
140
+ # Default-channel publication for the export's store. 5.5 transitional:
141
+ # fall back to the legacy Product columns when the publication dates are
142
+ # NULL (pre-backfill). 6.0 drops the Product-column fallback.
143
+ def default_publication
144
+ return @default_publication if defined?(@default_publication)
145
+
146
+ channel_id = store&.default_channel&.id
147
+ @default_publication = channel_id && product.product_publications.find do |p|
148
+ p.store_id == store.id && p.channel_id == channel_id
149
+ end
150
+ end
151
+
152
+ def publication_available_on
153
+ default_publication&.published_at || product.available_on
154
+ end
155
+
156
+ def publication_discontinue_on
157
+ default_publication&.unpublished_at || product.discontinue_on
158
+ end
159
+
140
160
  def price_only_row
141
161
  csv = Array.new(CSV_HEADERS.size)
142
162
  csv[CSV_HEADERS.index('sku')] = variant.sku
@@ -58,7 +58,8 @@ module Spree
58
58
  status: product.status,
59
59
  sku: product.sku,
60
60
  in_stock: product.in_stock?,
61
- store_ids: product.store_ids.map(&:to_s),
61
+ store_ids: Array(product.store_id).map(&:to_s),
62
+ channel_ids: channel_ids_for_store,
62
63
  discontinue_on: product.discontinue_on&.to_i || 0,
63
64
  category_ids: category_ids_with_ancestors,
64
65
  category_names: product.taxons.map { |t| translated(t, :name, fallback_locale) },
@@ -67,7 +68,7 @@ module Spree
67
68
  option_value_ids: variant_option_value_ids,
68
69
  option_values: variant_option_values_data.map { |ov| translated(ov, :presentation, fallback_locale) }.uniq,
69
70
  tags: product.tag_list || [],
70
- units_sold_count: product.store_products.find { |sp| sp.store_id == store.id }&.units_sold_count || 0,
71
+ units_sold_count: product.units_sold_count || 0,
71
72
  available_on: product.available_on&.iso8601,
72
73
  created_at: product.created_at&.iso8601,
73
74
  updated_at: product.updated_at&.iso8601
@@ -107,8 +108,14 @@ module Spree
107
108
  @compare_at_cache[currency]
108
109
  end
109
110
 
110
- # Include ancestor category IDs so filtering by a parent category
111
- # matches products classified under its descendants.
111
+ def channel_ids_for_store
112
+ @channel_ids_for_store ||= product.product_publications
113
+ .joins(:channel)
114
+ .where(spree_channels: { store_id: store.id })
115
+ .pluck(:channel_id)
116
+ .map(&:to_s)
117
+ end
118
+
112
119
  def category_ids_with_ancestors
113
120
  @category_ids_with_ancestors ||= product.taxons.flat_map { |t|
114
121
  t.self_and_ancestors.map(&:prefixed_id)
@@ -16,10 +16,11 @@ module Spree
16
16
 
17
17
  def call
18
18
  @variants.map do |variant|
19
+ price = variant.price_in(current_currency)
19
20
  {
20
- display_price: display_price(variant),
21
- price: variant.price_in(current_currency),
22
- display_compare_at_price: display_compare_at_price(variant),
21
+ display_price: price.display_price_including_vat_for(current_price_options).to_html,
22
+ price: price,
23
+ display_compare_at_price: price.display_compare_at_price_including_vat_for(current_price_options).to_html,
23
24
  should_display_compare_at_price: should_display_compare_at_price?(variant),
24
25
  is_product_available_in_currency: @is_product_available_in_currency,
25
26
  backorderable: backorderable?(variant),