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
@@ -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
@@ -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),
@@ -11,8 +11,6 @@ module Spree
11
11
  default_billing = address_params.key?(:is_default_billing) ? address_params.delete(:is_default_billing) : opts.fetch(:default_billing, false)
12
12
  default_shipping = address_params.key?(:is_default_shipping) ? address_params.delete(:is_default_shipping) : opts.fetch(:default_shipping, false)
13
13
  address_changes_except = opts.fetch(:address_changes_except, [])
14
- create_new_address_on_update = opts.fetch(:create_new_address_on_update, false)
15
- Spree::Deprecation.warn('Spree::Addresses::Update create_new_address_on_update parameter is deprecated and will be removed in Spree 5.5.') if create_new_address_on_update
16
14
 
17
15
  prepare_address_params!(address, address_params)
18
16
  address.assign_attributes(address_params)
@@ -36,7 +34,7 @@ module Spree
36
34
 
37
35
  return success(address) unless address_changed
38
36
 
39
- if address.editable? && !create_new_address_on_update
37
+ if address.editable?
40
38
  if address.update(address_params)
41
39
  if address.user.present?
42
40
  assign_to_user_as_default(
@@ -54,11 +52,11 @@ module Spree
54
52
  failure(address)
55
53
  end
56
54
  elsif new_address(address_params).valid?
57
- address.destroy unless create_new_address_on_update
55
+ address.destroy
58
56
 
59
57
  if new_address.user.present?
60
- default_billing = (!create_new_address_on_update && address.user_default_billing?) || default_billing
61
- default_shipping = (!create_new_address_on_update && address.user_default_shipping?) || default_shipping
58
+ default_billing = address.user_default_billing? || default_billing
59
+ default_shipping = address.user_default_shipping? || default_shipping
62
60
 
63
61
  assign_to_user_as_default(
64
62
  user: new_address.user,
@@ -69,8 +67,8 @@ module Spree
69
67
  end
70
68
 
71
69
  if order.present?
72
- order.ship_address = new_address if !create_new_address_on_update && order.ship_address_id == address.id
73
- order.bill_address = new_address if !create_new_address_on_update && order.bill_address_id == address.id
70
+ order.ship_address = new_address if order.ship_address_id == address.id
71
+ order.bill_address = new_address if order.bill_address_id == address.id
74
72
  order.state = 'address'
75
73
  order.save
76
74
  end
@@ -6,6 +6,7 @@ module Spree
6
6
  def call(order:, variant:, quantity: nil, metadata: {}, public_metadata: {}, private_metadata: {}, options: {})
7
7
  ApplicationRecord.transaction do
8
8
  run :add_to_line_item
9
+ run :handle_stock_reservations
9
10
  run Spree.cart_recalculate_service
10
11
  end
11
12
  end
@@ -48,6 +49,15 @@ module Spree
48
49
  ::Spree::TaxRate.adjust(order, [line_item]) if line_item_created
49
50
  success(order: order, line_item: line_item, line_item_created: line_item_created, options: options)
50
51
  end
52
+
53
+ def handle_stock_reservations(order:, line_item:, line_item_created:, options:)
54
+ if order.in_checkout?
55
+ result = Spree::StockReservations::Reserve.call(order: order)
56
+ return failure(line_item, result.error) if result.failure?
57
+ end
58
+
59
+ success(order: order, line_item: line_item, line_item_created: line_item_created, options: options)
60
+ end
51
61
  end
52
62
  end
53
63
  end
@@ -28,6 +28,8 @@ module Spree
28
28
  order.persist_totals
29
29
  order.restart_checkout_flow
30
30
 
31
+ Spree::StockReservations::Release.call(order: order)
32
+
31
33
  success(order)
32
34
  end
33
35
  end
@@ -7,11 +7,21 @@ module Spree
7
7
  options ||= {}
8
8
  ActiveRecord::Base.transaction do
9
9
  order.line_items.destroy(line_item)
10
+
11
+ # LineItem dependent: :destroy removes its own reservation row;
12
+ # remaining items may need a fresh reservation pass when in checkout.
13
+ if order.in_checkout? && order.line_items.any?
14
+ result = Spree::StockReservations::Reserve.call(order: order)
15
+ raise Spree::StockReservations::InsufficientStockError.new(nil, result.error.to_s) if result.failure?
16
+ end
17
+
10
18
  Spree.cart_recalculate_service.new.call(order: order,
11
19
  line_item: line_item,
12
20
  options: options)
13
21
  end
14
22
  success(line_item)
23
+ rescue Spree::StockReservations::InsufficientStockError => e
24
+ failure(line_item, e.message)
15
25
  end
16
26
  end
17
27
  end
@@ -9,7 +9,7 @@ module Spree
9
9
 
10
10
  return success([order, @messages, @warnings]) if order.item_count.zero? || order.line_items.none?
11
11
 
12
- line_items = order.line_items.includes(variant: [:product, :stock_items, :stock_locations, { stock_items: :stock_location }])
12
+ line_items = order.line_items.includes(variant: [:product, :stock_locations, { stock_items: [:stock_location, :active_stock_reservations] }])
13
13
 
14
14
  ActiveRecord::Base.transaction do
15
15
  line_items.each do |line_item|
@@ -6,6 +6,7 @@ module Spree
6
6
  def call(order:, line_item:, quantity: nil)
7
7
  ActiveRecord::Base.transaction do
8
8
  run :change_item_quantity
9
+ run :handle_stock_reservations
9
10
  run Spree.cart_recalculate_service
10
11
  end
11
12
  end
@@ -17,6 +18,15 @@ module Spree
17
18
 
18
19
  success(order: order, line_item: line_item)
19
20
  end
21
+
22
+ def handle_stock_reservations(order:, line_item:)
23
+ if order.in_checkout?
24
+ result = Spree::StockReservations::Reserve.call(order: order)
25
+ return failure(line_item, result.error) if result.failure?
26
+ end
27
+
28
+ success(order: order, line_item: line_item)
29
+ end
20
30
  end
21
31
  end
22
32
  end
@@ -19,6 +19,7 @@ module Spree
19
19
  advance_to_complete!(cart)
20
20
 
21
21
  if cart.reload.complete?
22
+ Spree::StockReservations::Release.call(order: cart)
22
23
  success(cart)
23
24
  else
24
25
  failure(cart, cart.errors.full_messages.to_sentence.presence || 'Could not complete checkout')
@@ -12,6 +12,7 @@ module Spree
12
12
  cart = store.carts.create!(
13
13
  user: @params.delete(:user),
14
14
  market: @params.delete(:market) || Spree::Current.market,
15
+ channel: @params.delete(:channel) || Spree::Current.channel,
15
16
  currency: @params.delete(:currency) || store.default_currency,
16
17
  locale: @params.delete(:locale) || Spree::Current.locale
17
18
  )
@@ -6,6 +6,7 @@ module Spree
6
6
  def call(cart:, params:)
7
7
  @cart = cart
8
8
  @params = params.to_h.deep_symbolize_keys
9
+ was_in_cart = cart.cart?
9
10
 
10
11
  ApplicationRecord.transaction do
11
12
  assign_cart_attributes
@@ -16,10 +17,10 @@ module Spree
16
17
  cart.save!
17
18
 
18
19
  process_items
20
+ try_advance
21
+ sync_stock_reservations(was_in_cart: was_in_cart)
19
22
  end
20
23
 
21
- try_advance
22
-
23
24
  success(cart)
24
25
  rescue ActiveRecord::RecordNotFound
25
26
  raise
@@ -110,6 +111,21 @@ module Spree
110
111
  cart.state = 'address'
111
112
  end
112
113
 
114
+ # Three-way dispatch on the cart→checkout transition:
115
+ # entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
116
+ # A failed Reserve raises so the enclosing transaction rolls back and the
117
+ # outer rescue surfaces the error to the API caller.
118
+ def sync_stock_reservations(was_in_cart:)
119
+ if cart.cart?
120
+ Spree::StockReservations::Release.call(order: cart) unless was_in_cart
121
+ elsif was_in_cart
122
+ result = Spree::StockReservations::Reserve.call(order: cart)
123
+ raise Spree::StockReservations::InsufficientStockError.new(nil, result.error.to_s) if result.failure?
124
+ else
125
+ Spree::StockReservations::Extend.call(order: cart)
126
+ end
127
+ end
128
+
113
129
  # Auto-advance as far as the checkout state machine allows, but never
114
130
  # to complete. The complete transition must always be explicit via
115
131
  # the /carts/:id/complete endpoint — otherwise gift cards or store
@@ -57,16 +57,16 @@ module Spree
57
57
 
58
58
  private
59
59
 
60
- def resolve_variant(store, prefixed_id)
61
- return nil if prefixed_id.blank?
60
+ def resolve_variant(store, variant_id)
61
+ return nil if variant_id.blank?
62
62
 
63
- variant = store.variants.find_by_prefix_id(prefixed_id)
63
+ variant = store.variants.find_by_param(variant_id)
64
64
 
65
65
  raise ActiveRecord::RecordNotFound.new(
66
- "Variant '#{prefixed_id}' not found in this store",
66
+ "Variant '#{variant_id}' not found in this store",
67
67
  'Spree::Variant',
68
- 'prefix_id',
69
- prefixed_id
68
+ 'id',
69
+ variant_id
70
70
  ) unless variant
71
71
 
72
72
  variant
@@ -34,7 +34,10 @@ module Spree
34
34
  end
35
35
 
36
36
  def assign_address(user)
37
- address = user.bill_address || user.build_bill_address
37
+ # Save user first so the address can FK to it via user_id (has_many :addresses).
38
+ user.save! if user.new_record?
39
+
40
+ address = user.bill_address || user.addresses.build
38
41
  address.firstname = attributes['first_name'].presence || user.first_name
39
42
  address.lastname = attributes['last_name'].presence || user.last_name
40
43
  address.company = attributes['company'].strip if attributes['company'].present?
@@ -66,9 +66,18 @@ module Spree
66
66
  product = existing_product if existing_product.present?
67
67
  end
68
68
 
69
- product = assign_attributes_to_product(product)
70
- product.save!
71
- handle_metafields(product) if has_product_attributes?
69
+ # Store is touched when the import completes
70
+ Spree::Store.no_touching do
71
+ product = assign_attributes_to_product(product)
72
+ product.save!
73
+ end
74
+
75
+ handle_tags(product) if attributes['tags'].present?
76
+ if has_product_attributes?
77
+ handle_metafields(product)
78
+ handle_categories(product)
79
+ end
80
+
72
81
  product
73
82
  else
74
83
  # For non-master variants, only look up the product
@@ -89,27 +98,21 @@ module Spree
89
98
  if product.new_record?
90
99
  product.slug = attributes['slug']
91
100
  product.sku = attributes['sku'] if attributes['sku'].present? && options.empty?
101
+ product.store = store
92
102
  end
93
103
 
94
- product.stores << store if product.stores.exclude?(store)
95
104
  product.name = attributes['name'] if attributes['name'].present?
96
105
  product.description = attributes['description'] if attributes['description'].present?
97
106
  product.meta_title = attributes['meta_title'] if attributes['meta_title'].present?
98
107
  product.meta_description = attributes['meta_description'] if attributes['meta_description'].present?
99
108
  product.meta_keywords = attributes['meta_keywords'] if attributes['meta_keywords'].present?
100
109
  product.status = to_spree_status(attributes['status']) if attributes['status'].present?
101
- product.tag_list = attributes['tags'] if attributes['tags'].present?
102
110
 
103
111
  if options.empty?
104
112
  if attributes['shipping_category'].present?
105
113
  shipping_category = prepare_shipping_category
106
114
  product.shipping_category = shipping_category if shipping_category.present?
107
115
  end
108
-
109
- taxons = prepare_taxons
110
- # Full product rows (with name/status/description) clear taxons when categories are blank.
111
- # Price-only rows (no product attributes) never touch taxons.
112
- product.taxons = taxons if taxons.any? || has_product_attributes?
113
116
  end
114
117
 
115
118
  product
@@ -125,47 +128,73 @@ module Spree
125
128
  Spree::TaxCategory.find_by(name: tax_category_name)
126
129
  end
127
130
 
128
- def prepare_taxons
129
- taxon_pretty_names = [
130
- attributes['category1'],
131
- attributes['category2'],
132
- attributes['category3']
133
- ].compact_blank.map(&:strip).uniq
131
+ def prepare_option_value_variants
132
+ return [] if options.empty?
134
133
 
135
- return [] if taxon_pretty_names.empty?
134
+ ActiveRecord::Base.no_touching do
135
+ options.map do |option|
136
+ option_type = find_or_create_option_type!(option[:option_name])
137
+ option_value = find_or_create_option_value!(option_type, option[:option_value])
136
138
 
137
- taxons = taxon_pretty_names.map { |taxon_pretty_name| handle_taxon_line(taxon_pretty_name) }
138
- taxons.compact
139
+ # ensure product option types include new option type
140
+ find_or_create_product_option_type!(option_type)
141
+
142
+ Spree::OptionValueVariant.new(option_value: option_value)
143
+ end
144
+ end
139
145
  end
140
146
 
141
- def handle_taxon_line(taxon_pretty_name)
142
- taxon_names = taxon_pretty_name.strip.split('->').map(&:strip).map(&:presence).compact
143
- return if taxon_names.empty?
147
+ def options
148
+ @options ||= begin
149
+ options = []
144
150
 
145
- taxonomy_name = taxon_names.shift
146
- taxonomy = store.taxonomies.with_matching_name(taxonomy_name).first || store.taxonomies.create!(name: taxonomy_name)
151
+ OPTION_TYPES_COUNT.times.map do |index|
152
+ next if attributes["option#{index + 1}_name"].blank?
153
+ next if attributes["option#{index + 1}_value"].blank?
147
154
 
148
- last_taxon = taxonomy.root
155
+ options << {
156
+ index: index + 1,
157
+ option_name: attributes["option#{index + 1}_name"],
158
+ option_value: attributes["option#{index + 1}_value"]
159
+ }
160
+ end
149
161
 
150
- taxon_names.each do |taxon_name|
151
- last_taxon = taxonomy.taxons.with_matching_name(taxon_name).where(parent: last_taxon).first || taxonomy.taxons.create!(name: taxon_name, parent: last_taxon)
162
+ options
152
163
  end
164
+ end
165
+
166
+ # Concurrent CSV imports can race when creating shared OptionTypes/OptionValues.
167
+ # Recover the losing worker by re-fetching the peer's row whether the conflict
168
+ # surfaces via the DB unique index (RecordNotUnique) or the AR uniqueness
169
+ # validator (RecordInvalid with a :taken error on the relevant attribute).
170
+ def find_or_create_option_type!(presentation)
171
+ Spree::OptionType.search_by_name(presentation).first || Spree::OptionType.create!(presentation: presentation)
172
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
173
+ raise unless uniqueness_conflict?(e, :name)
153
174
 
154
- last_taxon
175
+ Spree::OptionType.search_by_name(presentation).first!
155
176
  end
156
177
 
157
- def prepare_option_value_variants
158
- return [] if options.empty?
178
+ def find_or_create_option_value!(option_type, presentation)
179
+ option_type.option_values.search_by_name(presentation).first || option_type.option_values.create!(presentation: presentation)
180
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
181
+ raise unless uniqueness_conflict?(e, :name)
159
182
 
160
- options.map do |option|
161
- option_type = Spree::OptionType.search_by_name(option[:option_name]).first || Spree::OptionType.create!(presentation: option[:option_name])
162
- option_value = option_type.option_values.search_by_name(option[:option_value]).first || option_type.option_values.create!(presentation: option[:option_value])
183
+ option_type.option_values.search_by_name(presentation).first!
184
+ end
163
185
 
164
- # ensure product option types include new option type
165
- Spree::ProductOptionType.find_or_create_by!(product: product, option_type: option_type)
186
+ def find_or_create_product_option_type!(option_type)
187
+ Spree::ProductOptionType.find_or_create_by!(product: product, option_type: option_type)
188
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
189
+ raise unless uniqueness_conflict?(e, :product_id)
166
190
 
167
- Spree::OptionValueVariant.new(option_value: option_value)
168
- end
191
+ Spree::ProductOptionType.find_by!(product: product, option_type: option_type)
192
+ end
193
+
194
+ # RecordNotUnique is always a uniqueness conflict; RecordInvalid only when the
195
+ # given attribute has a :taken error (other validation failures must propagate).
196
+ def uniqueness_conflict?(error, attribute)
197
+ error.is_a?(ActiveRecord::RecordNotUnique) || error.record.errors.where(attribute, :taken).any?
169
198
  end
170
199
 
171
200
  def handle_images(variant)
@@ -177,27 +206,20 @@ module Spree
177
206
 
178
207
  return if image_urls.empty?
179
208
 
180
- image_urls.each do |image_url|
181
- Spree::Images::SaveFromUrlJob.perform_later(variant.id, 'Spree::Variant', image_url)
182
- end
183
- end
184
-
185
- def options
186
- @options ||= begin
187
- options = []
209
+ # Always attach to the product so blobs aren't duplicated across
210
+ # variants. For non-master rows, pass the variant id so the job links
211
+ # the resulting product-level asset to that variant via VariantMedia.
212
+ link_variant_id = variant.is_master? ? nil : variant.id
188
213
 
189
- OPTION_TYPES_COUNT.times.map do |index|
190
- next if attributes["option#{index + 1}_name"].blank?
191
- next if attributes["option#{index + 1}_value"].blank?
192
-
193
- options << {
194
- index: index + 1,
195
- option_name: attributes["option#{index + 1}_name"],
196
- option_value: attributes["option#{index + 1}_value"]
197
- }
198
- end
199
-
200
- options
214
+ image_urls.each do |image_url|
215
+ Spree::Images::SaveFromUrlJob.perform_later(
216
+ product.id,
217
+ 'Spree::Product',
218
+ image_url,
219
+ nil,
220
+ nil,
221
+ link_variant_id
222
+ )
201
223
  end
202
224
  end
203
225
 
@@ -251,6 +273,22 @@ module Spree
251
273
  product.update(metafields_attributes: nested_attrs) unless nested_attrs.empty?
252
274
  end
253
275
 
276
+ def handle_tags(product)
277
+ Spree::Imports::AssignTagsJob.perform_later(product.id, attributes['tags'])
278
+ end
279
+
280
+ def handle_categories(product)
281
+ Spree::Imports::CreateCategoriesJob.perform_later(product.id, store.id, prepare_taxon_pretty_names)
282
+ end
283
+
284
+ def prepare_taxon_pretty_names
285
+ [
286
+ attributes['category1'],
287
+ attributes['category2'],
288
+ attributes['category3']
289
+ ].compact_blank.map(&:strip).uniq
290
+ end
291
+
254
292
  def to_spree_status(status)
255
293
  case status.strip.downcase
256
294
  when 'active'
@@ -0,0 +1,53 @@
1
+ module Spree
2
+ module Newsletter
3
+ # Reconciles a Spree::NewsletterSubscriber with the customer who owns the email.
4
+ # Backfills the user link and propagates verified opt-in onto the user record so
5
+ # consent given before account creation isn't silently lost on registration.
6
+ #
7
+ # Best-effort: validation failures are logged but never re-raised. Callers are
8
+ # already past the point where rolling back makes sense (the user record exists,
9
+ # the subscription exists). This is reconciliation, not a precondition.
10
+ class LinkUser
11
+ def initialize(subscriber:, user:)
12
+ @subscriber = subscriber
13
+ @user = user
14
+ end
15
+
16
+ def call
17
+ return if subscriber.blank? || user.blank?
18
+ return if subscriber.user_id == user.id && !needs_marketing_propagation?
19
+
20
+ link_subscriber_to_user
21
+ propagate_marketing_consent if needs_marketing_propagation?
22
+
23
+ subscriber
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :subscriber, :user
29
+
30
+ def link_subscriber_to_user
31
+ return if subscriber.user_id == user.id
32
+
33
+ return if subscriber.update(user: user)
34
+
35
+ Rails.logger.warn(
36
+ "NewsletterSubscriber #{subscriber.id} link to user #{user.id} failed: #{subscriber.errors.full_messages.to_sentence}"
37
+ )
38
+ end
39
+
40
+ def needs_marketing_propagation?
41
+ subscriber.verified? && !user.accepts_email_marketing?
42
+ end
43
+
44
+ def propagate_marketing_consent
45
+ return if user.update(accepts_email_marketing: true)
46
+
47
+ Rails.logger.warn(
48
+ "User #{user.id} accepts_email_marketing update failed: #{user.errors.full_messages.to_sentence}"
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end