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
@@ -27,13 +27,14 @@ module Spree
27
27
  normalizes :name, with: ->(value) { value&.to_s&.squish&.presence }
28
28
 
29
29
  include Spree::ProductScopes
30
- include Spree::StoreScopedResource
31
30
  include Spree::TranslatableResource
32
31
  include Spree::MemoizedData
33
32
  include Spree::Metafields
34
33
  include Spree::Metadata
35
34
  include Spree::Product::Webhooks
36
35
  include Spree::Product::Slugs
36
+ include Spree::Product::Channels
37
+ include Spree::Product::LegacyMultiStoreSupport unless defined?(SpreeMultiStore)
37
38
  include Spree::SearchIndexable
38
39
  if defined?(Spree::VendorConcern)
39
40
  include Spree::VendorConcern
@@ -121,8 +122,6 @@ module Spree
121
122
 
122
123
  has_many :prices_including_master, -> { non_zero }, through: :variants_including_master, source: :prices
123
124
 
124
- has_many :store_products, class_name: 'Spree::StoreProduct'
125
- has_many :stores, through: :store_products, class_name: 'Spree::Store'
126
125
  has_many :digitals, through: :variants_including_master
127
126
 
128
127
  after_initialize :ensure_master
@@ -133,6 +132,8 @@ module Spree
133
132
 
134
133
  after_create :add_associations_from_prototype
135
134
  after_create :build_variants_from_option_values_hash, if: :option_values_hash
135
+ after_create :apply_pending_variants, if: :pending_variants?
136
+ after_save :apply_pending_media, if: :pending_media?
136
137
 
137
138
  after_save :save_master
138
139
  after_save :run_touch_callbacks, if: :anything_changed?
@@ -153,7 +154,7 @@ module Spree
153
154
 
154
155
  validate :discontinue_on_must_be_later_than_make_active_at, if: -> { make_active_at && discontinue_on }
155
156
 
156
- scope :for_store, ->(store) { joins(:store_products).where(StoreProduct.table_name => { store_id: store.id }) }
157
+ scope :for_store, ->(store) { where(store_id: store.id) }
157
158
  scope :draft, -> { where(status: 'draft') }
158
159
  scope :archived, -> { where(status: 'archived') }
159
160
  scope :not_archived, -> { where.not(status: 'archived') }
@@ -197,8 +198,71 @@ module Spree
197
198
 
198
199
  alias options product_option_types
199
200
 
201
+ # Maps tags array to tag_list for API convenience.
202
+ # @param tags [Array<String>]
203
+ def tags=(tags)
204
+ self.tag_list = tags
205
+ end
206
+
207
+ # Sets prices on the master variant.
208
+ # Accepts array of { currency:, amount:, compare_at_amount: } hashes.
209
+ def prices=(prices_params)
210
+ find_or_build_master.prices = prices_params
211
+ end
212
+
213
+ # Maps 6.0 API name (category_ids) to model column (taxon_ids).
214
+ # Accepts both prefixed IDs and raw integer IDs.
215
+ def category_ids=(ids)
216
+ self.taxon_ids = Array(ids).filter_map do |id|
217
+ id.to_s.include?('_') ? Spree::Taxon.decode_prefixed_id(id) : id
218
+ end
219
+ end
220
+
221
+ # Sync media inline. Entries with `id` patch the existing asset
222
+ # (alt/position/variant_ids); entries with `signed_id` create + attach a
223
+ # fresh upload; missing items are left alone (delete still goes through
224
+ # the dedicated DELETE /media endpoint to avoid accidental data loss when
225
+ # a form ships stale state).
226
+ #
227
+ # Deferred: ActiveStorage attaches require a persisted record, so on new
228
+ # records we stash the params and replay them in `after_create`.
229
+ # @param media_params [Array<Hash>]
230
+ # @return [void]
231
+ def media=(media_params)
232
+ # Blank input is a no-op — never call `super` with an empty array,
233
+ # because the ActiveRecord collection setter would replace media with
234
+ # `[]` and trigger `dependent: :destroy` on every persisted asset.
235
+ # Explicit deletes go through the dedicated DELETE /media endpoint.
236
+ return if media_params.blank?
237
+ return super if media_params.first.is_a?(Spree::Asset)
238
+
239
+ if new_record?
240
+ @pending_media_params = media_params
241
+ return
242
+ end
243
+
244
+ apply_media(media_params)
245
+ end
246
+
247
+ # Syncs variants from an array of hashes.
248
+ # Creates new variants, updates existing ones (matched by :id), and removes unlisted ones.
249
+ # Must be called on a persisted product (use after_save or call explicitly after create).
250
+ # @param variants_params [Array<Hash>] array of variant attribute hashes
251
+ # @return [void]
252
+ def variants=(variants_params)
253
+ return super if variants_params.blank? || variants_params.first.is_a?(Spree::Variant)
254
+
255
+ # Store for deferred processing if product is not yet persisted
256
+ if new_record?
257
+ @pending_variants_params = variants_params
258
+ return
259
+ end
260
+
261
+ apply_variants(variants_params)
262
+ end
263
+
200
264
  self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status available_on created_at updated_at]
201
- self.whitelisted_ransackable_associations = %w[taxons categories stores variants_including_master master variants tags labels
265
+ self.whitelisted_ransackable_associations = %w[taxons categories store channels variants_including_master master variants tags labels
202
266
  shipping_category classifications option_types]
203
267
  self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon in_category in_categories price_between
204
268
  price_lte price_gte
@@ -495,7 +559,11 @@ module Spree
495
559
  @total_on_hand ||= if any_variants_not_track_inventory?
496
560
  BigDecimal::INFINITY
497
561
  else
498
- stock_items.loaded? ? stock_items.sum(&:count_on_hand) : stock_items.sum(:count_on_hand)
562
+ if variants_including_master.loaded?
563
+ variants_including_master.sum(&:total_on_hand)
564
+ else
565
+ stock_items.loaded? ? stock_items.sum(&:count_on_hand) : stock_items.sum(:count_on_hand)
566
+ end
499
567
  end
500
568
  end
501
569
 
@@ -506,19 +574,6 @@ module Spree
506
574
  super || variants_including_master.with_deleted.find_by(is_master: true)
507
575
  end
508
576
 
509
- # Returns the brand for the product
510
- # If a brand association is defined (e.g., belongs_to :brand), it will be used
511
- # Otherwise, falls back to brand_taxon for compatibility
512
- # @return [Spree::Brand, Spree::Taxon]
513
- def brand
514
- if self.class.reflect_on_association(:brand)
515
- super
516
- else
517
- Spree::Deprecation.warn('Spree::Product#brand is deprecated and will be removed in Spree 5.5. Please use Spree::Product#brand_taxon instead.')
518
- brand_taxon
519
- end
520
- end
521
-
522
577
  # Returns the brand taxon for the product
523
578
  # @return [Spree::Taxon]
524
579
  def brand_taxon
@@ -538,7 +593,7 @@ module Spree
538
593
  # Returns the brand name for the product
539
594
  # @return [String]
540
595
  def brand_name
541
- brand&.name
596
+ brand_taxon&.name
542
597
  end
543
598
 
544
599
  def main_taxon
@@ -573,15 +628,13 @@ module Spree
573
628
  def auto_match_taxons
574
629
  return if deleted?
575
630
  return if archived?
576
-
577
- store = stores.find_by(default: true) || stores.first
578
631
  return if store.nil? || store.taxons.automatic.none?
579
632
 
580
633
  Spree::Products::AutoMatchTaxonsJob.set(wait: 30.seconds).perform_later(id)
581
634
  end
582
635
 
583
636
  def to_csv(store = nil)
584
- store ||= stores.default || stores.first
637
+ store ||= self.store
585
638
  properties_for_csv = if respond_to?(:product_properties) && Spree::Config.respond_to?(:product_properties_enabled) && Spree::Config[:product_properties_enabled]
586
639
  Spree::Property.order(:position).flat_map do |property|
587
640
  [
@@ -645,6 +698,100 @@ module Spree
645
698
  nil
646
699
  end
647
700
 
701
+ def pending_variants?
702
+ @pending_variants_params.present?
703
+ end
704
+
705
+ def apply_pending_variants
706
+ return unless @pending_variants_params
707
+
708
+ apply_variants(@pending_variants_params)
709
+ @pending_variants_params = nil
710
+ end
711
+
712
+ def pending_media?
713
+ @pending_media_params.present?
714
+ end
715
+
716
+ def apply_pending_media
717
+ return unless @pending_media_params
718
+
719
+ apply_media(@pending_media_params)
720
+ @pending_media_params = nil
721
+ end
722
+
723
+ def apply_media(media_params)
724
+ # Eager-load Asset descendants once so the type allowlist is stable across
725
+ # the loop (and across requests once subclasses are referenced). Computed
726
+ # per-call rather than at class-load to avoid forcing autoload of every
727
+ # Asset subclass during boot.
728
+ allowed_types = [Spree::Asset, *Spree::Asset.descendants].map(&:name).to_set
729
+ media_params.each do |raw|
730
+ attrs = raw.respond_to?(:to_h) ? raw.to_h : raw
731
+ attrs = attrs.with_indifferent_access
732
+
733
+ # Upsert path: entries with an `id` patch an existing asset (alt,
734
+ # position, variant_ids). Entries with a `signed_id` create+attach.
735
+ # Omitting an entry leaves it alone — explicit DELETE on the dedicated
736
+ # media endpoint is still the only way to remove an asset.
737
+ asset_id = attrs.delete(:id)
738
+ if asset_id.present?
739
+ asset = media.find_by_param(asset_id) || next
740
+ asset.update!(attrs.except(:signed_id, :type))
741
+ next
742
+ end
743
+
744
+ signed_id = attrs.delete(:signed_id)
745
+ next if signed_id.blank?
746
+
747
+ media_type = attrs.delete(:type) || 'Spree::Image'
748
+ next unless allowed_types.include?(media_type)
749
+
750
+ asset = media.build(attrs.except(:id))
751
+ asset.type = media_type
752
+ asset.attachment.attach(signed_id)
753
+ asset.save!
754
+ end
755
+ end
756
+
757
+ def apply_variants(variants_params)
758
+ variant_ids_in_payload = []
759
+ master_touched = false
760
+
761
+ variants_params.each do |variant_data|
762
+ variant_data = variant_data.to_h.with_indifferent_access
763
+ variant_id = variant_data.delete(:id)
764
+ options = variant_data[:options]
765
+
766
+ if variant_id.present?
767
+ variant = variants_including_master.find_by_param!(variant_id)
768
+ variant.update!(variant_data)
769
+ variant_ids_in_payload << variant.id
770
+ elsif options.blank? || (options.is_a?(Array) && options.empty?)
771
+ # An entry with no options addresses the master variant. Building a
772
+ # non-master here would create a phantom duplicate (the auto-built
773
+ # master already exists, and `variants` excludes it). Upsert onto
774
+ # the master instead — the merchant-visible "default variant" on a
775
+ # simple product IS the master.
776
+ variant_data = variant_data.except(:options)
777
+ target = find_or_build_master
778
+ target.assign_attributes(variant_data)
779
+ target.save!
780
+ master_touched = true
781
+ else
782
+ variant = variants.build
783
+ variant.assign_attributes(variant_data)
784
+ variant.save!
785
+ variant_ids_in_payload << variant.id
786
+ end
787
+ end
788
+
789
+ # Remove variants not in the payload (only non-master). If only the
790
+ # master was touched (simple product), leave existing non-master
791
+ # variants alone — the payload is partial, not a full replacement.
792
+ variants.where.not(id: variant_ids_in_payload).destroy_all if variant_ids_in_payload.any? && !master_touched
793
+ end
794
+
648
795
  def add_associations_from_prototype
649
796
  if prototype_id && prototype = Spree::Prototype.find_by(id: prototype_id)
650
797
  self.option_types = prototype.option_types
@@ -668,7 +815,7 @@ module Spree
668
815
  values = option_values_hash.values
669
816
  values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) }
670
817
 
671
- default_currency = stores.first&.default_currency || Spree::Store.default.default_currency
818
+ default_currency = store&.default_currency || Spree::Store.default.default_currency
672
819
  master_price = master.price_in(default_currency).amount
673
820
 
674
821
  values.each do |ids|
@@ -678,12 +825,6 @@ module Spree
678
825
  save
679
826
  end
680
827
 
681
- def default_variant_cache_key
682
- Spree::Deprecation.warn('Spree::Product#default_variant_cache_key is deprecated and will be removed in Spree 5.5. Please remove any occurrences of it.')
683
-
684
- "spree/default-variant/#{cache_key_with_version}/#{Spree::Config[:track_inventory_levels]}"
685
- end
686
-
687
828
  def ensure_master
688
829
  return unless new_record?
689
830
 
@@ -691,7 +832,7 @@ module Spree
691
832
  end
692
833
 
693
834
  def assign_default_tax_category
694
- self.tax_category = Spree::TaxCategory.default if new_record?
835
+ self.tax_category = Spree::TaxCategory.default if new_record? && self[:tax_category_id].blank?
695
836
  end
696
837
 
697
838
  def anything_changed?
@@ -0,0 +1,43 @@
1
+ module Spree
2
+ # Per-channel publication record. A Product is "published" on a Channel when
3
+ # a ProductPublication exists for that pair; the optional window
4
+ # (+published_at+/+unpublished_at+) gates customer visibility.
5
+ #
6
+ # The owning Store is derived via +channel.store+ — no +store_id+ column
7
+ # lives on this table. Historic core had a +spree_products_stores+ join that
8
+ # also carried the Product↔Store relation; in 5.5+ that responsibility moves
9
+ # onto +Spree::Product#store_id+ directly, leaving this table single-purpose.
10
+ class ProductPublication < Spree.base_class
11
+ has_prefix_id :pp
12
+
13
+ belongs_to :product, class_name: 'Spree::Product', touch: true
14
+ belongs_to :channel, class_name: 'Spree::Channel'
15
+
16
+ validates :product, :channel, presence: true
17
+ validates :product_id, uniqueness: { scope: :channel_id }
18
+ validate :unpublished_at_after_published_at, if: -> { published_at && unpublished_at }
19
+
20
+ scope :published, lambda {
21
+ where('published_at IS NULL OR published_at <= ?', Time.current)
22
+ .where('unpublished_at IS NULL OR unpublished_at > ?', Time.current)
23
+ }
24
+
25
+ self.whitelisted_ransackable_attributes = %w[product_id channel_id published_at unpublished_at]
26
+ self.whitelisted_ransackable_associations = %w[product channel]
27
+
28
+ delegate :store, :store_id, to: :channel
29
+
30
+ def published?
31
+ (published_at.nil? || published_at <= Time.current) &&
32
+ (unpublished_at.nil? || unpublished_at > Time.current)
33
+ end
34
+
35
+ private
36
+
37
+ def unpublished_at_after_published_at
38
+ return if unpublished_at > published_at
39
+
40
+ errors.add(:unpublished_at, :must_be_after_published_at)
41
+ end
42
+ end
43
+ end
@@ -7,6 +7,10 @@ module Spree
7
7
 
8
8
  before_validation -> { self.calculator ||= Calculator::FlatPercentItemTotal.new }
9
9
 
10
+ def self.additional_permitted_attributes
11
+ [calculator: [:type, { preferences: {} }]]
12
+ end
13
+
10
14
  def perform(options = {})
11
15
  order = options[:order]
12
16
 
@@ -7,6 +7,10 @@ module Spree
7
7
 
8
8
  before_validation -> { self.calculator ||= Calculator::PercentOnLineItem.new }
9
9
 
10
+ def self.additional_permitted_attributes
11
+ [calculator: [:type, { preferences: {} }]]
12
+ end
13
+
10
14
  def perform(options = {})
11
15
  order = options[:order]
12
16
  promotion = options[:promotion]
@@ -8,6 +8,17 @@ module Spree
8
8
 
9
9
  after_save :handle_promotion_action_line_items
10
10
 
11
+ def self.additional_permitted_attributes
12
+ [line_items: [:variant_id, :quantity]]
13
+ end
14
+
15
+ # API v3 flat alias for `promotion_action_line_items_attributes`.
16
+ # Accepts an array of `{ variant_id:, quantity: }` rows; the list
17
+ # is the *desired* set, so anything missing on save is removed.
18
+ def line_items=(rows)
19
+ self.promotion_action_line_items_attributes = rows
20
+ end
21
+
11
22
  delegate :eligible?, to: :promotion
12
23
 
13
24
  # Adds a line item to the Order if the promotion is eligible
@@ -85,29 +96,36 @@ module Spree
85
96
 
86
97
  private
87
98
 
88
- # Handles the creation and updating of promotion action line items
89
- #
90
- # This is a hacky replacement for accepts_nested_attributes_for
91
- # that allows us to save the PromotionAction and PromotionActionLineItems
92
- # at the same time.
99
+ # Handles the creation, updating, and pruning of promotion action
100
+ # line items. The submitted list is the *desired* set — variants
101
+ # not present are deleted, ones that are get upserted. Accepts
102
+ # both the legacy Rails admin hash shape (`{ "0" => attrs }`) and
103
+ # a flat array from the API. Variant IDs may be raw or prefixed.
93
104
  def handle_promotion_action_line_items
94
105
  return unless promotion_action_line_items_attributes
95
106
 
96
- # remove the ones marked for destruction
97
- ids_for_destruction = promotion_action_line_items_attributes.map { |key, params| params["_destroy"] == "1" ? params["id"] : nil }.compact
98
- promotion_action_line_items.where(id: ids_for_destruction).delete_all if ids_for_destruction.present?
107
+ rows = promotion_action_line_items_attributes.is_a?(Hash) ? promotion_action_line_items_attributes.values : promotion_action_line_items_attributes
108
+ rows = rows.map { |row| row.respond_to?(:to_h) ? row.to_h.with_indifferent_access : row.with_indifferent_access }
109
+
110
+ rows = rows.map do |row|
111
+ variant_id = row['variant_id']
112
+ variant_id = Spree::Variant.find_by_param(variant_id)&.id if Spree::PrefixedId.prefixed_id?(variant_id)
113
+ row.merge('variant_id' => variant_id)
114
+ end
115
+
116
+ desired_variant_ids = rows.map { |row| row['variant_id'] }.compact
117
+ promotion_action_line_items.where.not(variant_id: desired_variant_ids).delete_all
99
118
 
100
- # upsert the rest
101
- records_for_upsert = promotion_action_line_items_attributes.map { |key, params| params["_destroy"] != "1" ? params : nil }.compact
119
+ return if rows.empty?
102
120
 
103
121
  opts = {}
104
- opts[:unique_by] = [:promotion_action_id, :variant_id] unless ActiveRecord::Base.connection.adapter_name == 'Mysql2'
122
+ opts[:unique_by] = [:promotion_action_id, :variant_id] unless mysql_adapter?
105
123
 
106
124
  promotion_action_line_items.upsert_all(
107
- records_for_upsert.map do |params|
125
+ rows.map do |params|
108
126
  {
109
- variant_id: params["variant_id"],
110
- quantity: params["quantity"],
127
+ variant_id: params['variant_id'],
128
+ quantity: params['quantity'],
111
129
  promotion_action_id: id
112
130
  }
113
131
  end,
@@ -1,39 +1,61 @@
1
- # A rule to limit a promotion based on shipment country.
1
+ # A rule to limit a promotion based on shipment country. Stores an
2
+ # array of ISO codes — countries are inherently identified by ISO
3
+ # in the API. The legacy single `country_id` / `country_iso`
4
+ # preferences still work; they fold into the multi-country list.
2
5
  module Spree
3
6
  class Promotion
4
7
  module Rules
5
8
  class Country < PromotionRule
6
- preference :country_id, :integer
7
- preference :country_iso, :string # Alternative way to configure the rule
9
+ preference :country_isos, :array, default: [], parse_on_set: lambda { |values|
10
+ normalize_id_preference.call(values).map(&:upcase)
11
+ }
12
+ preference :country_id, :integer # legacy single-country shortcut
13
+ preference :country_iso, :string # legacy ISO-based shortcut
8
14
 
9
15
  def applicable?(promotable)
10
16
  promotable.is_a?(Spree::Order)
11
17
  end
12
18
 
13
- def eligible?(order, options = {})
14
- if preferred_country_iso.present?
15
- validate_eligibility_by_country_iso(order)
16
- else
17
- validate_eligibility_by_country_id(order, options)
18
- end
19
+ def countries
20
+ isos = preferred_country_isos.presence || [preferred_country_iso].compact_blank
21
+ return Spree::Country.none if isos.blank?
22
+
23
+ Spree::Country.where(iso: isos.map { |s| s.to_s.upcase })
19
24
  end
20
25
 
21
- private
26
+ def eligible?(order, options = {})
27
+ allowed_isos = eligible_country_isos(order)
28
+ shipping_iso = options[:country_iso] || order.ship_address&.country_iso
22
29
 
23
- def validate_eligibility_by_country_id(order, options)
24
- country_id = options[:country_id] || order.ship_address.try(:country_id)
25
- return true if country_id == (preferred_country_id || order.store.default_country_id)
30
+ return true if allowed_isos.include?(shipping_iso)
26
31
 
27
32
  eligibility_errors.add(:base, eligibility_error_message(:wrong_country))
28
33
  false
29
34
  end
30
35
 
31
- def validate_eligibility_by_country_iso(order)
32
- country_iso = order.ship_address&.country_iso
33
- return true if country_iso == (preferred_country_iso || order.store.default_market&.default_country&.iso)
36
+ # Effective list of eligible country ISOs, merging legacy
37
+ # single-country preferences into the multi-country list.
38
+ # Order-of-precedence: explicit ISO list > legacy single ISO
39
+ # > legacy single ID > store default. Memoized per-instance —
40
+ # eligibility checks fire repeatedly per cart change.
41
+ def eligible_country_isos(order = nil)
42
+ @eligible_country_isos ||= compute_eligible_country_isos(order)
43
+ end
34
44
 
35
- eligibility_errors.add(:base, eligibility_error_message(:wrong_country))
36
- false
45
+ private
46
+
47
+ def compute_eligible_country_isos(order)
48
+ return preferred_country_isos.map { |v| v.to_s.upcase } if preferred_country_isos.present?
49
+ return [preferred_country_iso.to_s.upcase] if preferred_country_iso.present?
50
+
51
+ if preferred_country_id.present?
52
+ iso = Spree::Country.where(id: preferred_country_id).pick(:iso)
53
+ return [iso.to_s.upcase] if iso.present?
54
+ end
55
+
56
+ return [] if order.nil?
57
+
58
+ [order.store&.default_country&.iso, order.store&.default_market&.default_country&.iso].compact.map(&:upcase).uniq
37
59
  end
38
60
  end
39
61
  end
@@ -2,12 +2,21 @@ module Spree
2
2
  class Promotion
3
3
  module Rules
4
4
  class CustomerGroup < PromotionRule
5
- preference :customer_group_ids, :array, default: []
5
+ # Stored as raw IDs. Accepts prefixed IDs (`cg_…`) from API
6
+ # callers and decodes them on write so eligibility checks can
7
+ # compare against raw `customer_group_id` rows directly.
8
+ preference :customer_group_ids, :array, default: [], parse_on_set: normalize_id_preference(klass: Spree::CustomerGroup)
6
9
 
7
10
  def applicable?(promotable)
8
11
  promotable.is_a?(Spree::Order)
9
12
  end
10
13
 
14
+ def customer_groups
15
+ return Spree::CustomerGroup.none if preferred_customer_group_ids.blank?
16
+
17
+ Spree::CustomerGroup.where(id: preferred_customer_group_ids)
18
+ end
19
+
11
20
  def eligible?(order, _options = {})
12
21
  return false unless order.user_id.present?
13
22
  return false if preferred_customer_group_ids.empty?
@@ -13,6 +13,10 @@ module Spree
13
13
  dependent: :destroy
14
14
  has_many :products, through: :product_promotion_rules, class_name: 'Spree::Product'
15
15
 
16
+ def self.additional_permitted_attributes
17
+ [product_ids: []]
18
+ end
19
+
16
20
  #
17
21
  # Preferences
18
22
  #
@@ -10,11 +10,34 @@ module Spree
10
10
  dependent: :destroy
11
11
  has_many :taxons, through: :promotion_rule_taxons, class_name: 'Spree::Taxon'
12
12
 
13
+ def self.additional_permitted_attributes
14
+ [category_ids: []]
15
+ end
16
+
17
+ # Wire-format shorthand is `category` (the model is still `Taxon`
18
+ # pre-6.0 rename). `key` (instance) cascades through `api_type`.
19
+ def self.api_type
20
+ 'category'
21
+ end
22
+
23
+ # PrefixedId's auto-resolver in `assign_attributes` only fires
24
+ # when the `_ids` stem matches an association — `categories`
25
+ # doesn't, so decode prefixed IDs explicitly here.
26
+ def category_ids=(ids)
27
+ self.taxon_ids = Array(ids).map do |id|
28
+ Spree::PrefixedId.prefixed_id?(id) ? Spree::Taxon.find_by_param!(id).id : id
29
+ end
30
+ end
31
+
32
+ def category_ids
33
+ taxon_ids
34
+ end
35
+
13
36
  #
14
37
  # Preferences
15
38
  #
16
39
  MATCH_POLICIES = %w(any all)
17
- preference :match_policy, default: MATCH_POLICIES.first
40
+ preference :match_policy, :string, default: MATCH_POLICIES.first
18
41
 
19
42
  #
20
43
  # Attributes
@@ -10,6 +10,27 @@ module Spree
10
10
  dependent: :destroy
11
11
  has_many :users, through: :promotion_rule_users, class_name: "::#{Spree.user_class}"
12
12
 
13
+ # Customers, not admin users — the rule keys off `Spree::Order#user_id`.
14
+ # The data layer keeps the `users` association (legacy column name);
15
+ # the API exposes the same set as `customer_ids`.
16
+ def self.additional_permitted_attributes
17
+ [customer_ids: []]
18
+ end
19
+
20
+ # Wire-format shorthand is `customer` (the model is still `User`
21
+ # pre-6.0 rename, see docs/plans/6.0-platform-auth.md).
22
+ def self.api_type
23
+ 'customer'
24
+ end
25
+
26
+ def customer_ids
27
+ user_ids
28
+ end
29
+
30
+ def customer_ids=(ids)
31
+ self.user_ids = ids
32
+ end
33
+
13
34
  #
14
35
  # Attributes
15
36
  #
@@ -2,6 +2,12 @@ module Spree
2
2
  class Promotion
3
3
  module Rules
4
4
  class UserLoggedIn < PromotionRule
5
+ # Wire-format shorthand is `customer_logged_in` (the model is still
6
+ # `UserLoggedIn` pre-6.0 rename, see docs/plans/6.0-platform-auth.md).
7
+ def self.api_type
8
+ 'customer_logged_in'
9
+ end
10
+
5
11
  def applicable?(promotable)
6
12
  promotable.is_a?(Spree::Order)
7
13
  end
@@ -39,7 +39,8 @@ module Spree
39
39
  has_many :orders, through: :order_promotions, class_name: 'Spree::Order'
40
40
  has_many :store_promotions, class_name: 'Spree::StorePromotion'
41
41
  has_many :stores, class_name: 'Spree::Store', through: :store_promotions
42
- accepts_nested_attributes_for :promotion_actions, :promotion_rules
42
+
43
+ after_save :apply_pending_rules_and_actions, if: :pending_rules_or_actions?
43
44
 
44
45
  #
45
46
  # Callbacks
@@ -115,6 +116,21 @@ module Spree
115
116
  end
116
117
  end
117
118
 
119
+ # Flat-payload writer for `rules`. See
120
+ # {Spree::TypedAssociations#assign_typed_association}.
121
+ def rules=(rows)
122
+ assign_typed_association(:promotion_rules, rows)
123
+ end
124
+
125
+ # Mirrors `rules=` for promotion actions.
126
+ def actions=(rows)
127
+ assign_typed_association(:promotion_actions, rows)
128
+ end
129
+
130
+ def pending_rules_or_actions?
131
+ @pending_promotion_rules.present? || @pending_promotion_actions.present?
132
+ end
133
+
118
134
  def active?
119
135
  starts_at.present? && starts_at < Time.current && (expires_at.blank? || !expired?)
120
136
  end
@@ -283,6 +299,11 @@ module Spree
283
299
 
284
300
  private
285
301
 
302
+ def apply_pending_rules_and_actions
303
+ flush_pending_typed_association(:promotion_rules)
304
+ flush_pending_typed_association(:promotion_actions)
305
+ end
306
+
286
307
  def not_used?
287
308
  return true if orders.empty?
288
309