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
@@ -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
@@ -11,7 +11,7 @@ module Spree
11
11
  "#{self.class.name} is deprecated and will be removed in Spree 6.0. " \
12
12
  'Use card.destroy directly instead. Payment cleanup is now handled ' \
13
13
  'automatically by the before_destroy callback in PaymentSourceConcern.',
14
- caller
14
+ caller_locations(2)
15
15
  )
16
16
 
17
17
  if card.destroy
@@ -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
@@ -1,42 +1,64 @@
1
1
  module Spree
2
2
  module Newsletter
3
3
  class Subscribe
4
- def initialize(email:, current_user: nil)
4
+ def initialize(email:, current_user: nil, current_store: nil, redirect_url: nil)
5
5
  @email = email
6
6
  @current_user = current_user
7
+ @current_store = current_store || Spree::Store.current
8
+ @redirect_url = redirect_url
7
9
  end
8
10
 
9
11
  def call
10
- return existed_subscription if existed_subscription.present?
12
+ if existed_subscription.present?
13
+ Spree::Newsletter::LinkUser.new(subscriber: existed_subscription, user: known_user).call
14
+ return existed_subscription
15
+ end
11
16
 
12
17
  ActiveRecord::Base.transaction do
13
18
  upsert_subscriber
14
19
  return subscriber if subscriber.errors.any?
15
20
 
21
+ Spree::Newsletter::LinkUser.new(subscriber: subscriber, user: known_user).call
22
+
16
23
  if subscriber.email == current_user&.email
17
- # no need to verified since user email is already verified
24
+ # User's email is already verified by login skip the double opt-in.
18
25
  Spree::Newsletter::Verify.new(subscriber: subscriber).call
19
26
  end
20
27
  end
21
28
 
22
- # publish event to trigger email delivery via subscriber
23
- subscriber.publish_event('newsletter_subscriber.subscribed') unless subscriber.verified?
29
+ subscriber.publish_event('newsletter_subscriber.subscription_requested', subscription_requested_payload) unless subscriber.verified?
24
30
  subscriber
25
31
  end
26
32
 
27
33
  private
28
34
 
29
- attr_reader :email, :current_user
35
+ attr_reader :email, :current_user, :current_store, :redirect_url
36
+
37
+ def subscription_requested_payload
38
+ payload = {
39
+ id: subscriber.prefixed_id,
40
+ email: subscriber.email,
41
+ verification_token: subscriber.verification_token,
42
+ store_id: current_store.prefixed_id,
43
+ customer_id: subscriber.user&.prefixed_id
44
+ }
45
+ payload[:redirect_url] = redirect_url if redirect_url.present?
46
+ payload
47
+ end
30
48
 
31
49
  def upsert_subscriber
32
- @upsert_subscriber ||= Spree::NewsletterSubscriber.find_or_create_by(email: email) do |new_record|
33
- new_record.user = Spree.user_class.find_by(email: new_record.email) || current_user
50
+ @upsert_subscriber ||= Spree::NewsletterSubscriber.find_or_create_by(email: email, store: current_store) do |new_record|
51
+ new_record.user = known_user
34
52
  end
35
53
  end
36
54
  alias_method :subscriber, :upsert_subscriber
37
55
 
38
56
  def existed_subscription
39
- @existed_subscription ||= Spree::NewsletterSubscriber.verified.find_by(email: email)
57
+ @existed_subscription ||= Spree::NewsletterSubscriber.verified.find_by(email: email, store: current_store)
58
+ end
59
+
60
+ def known_user
61
+ @known_user ||= current_user || Spree.user_class.find_by(email: email)
40
62
  end
41
63
  end
42
64
  end
@@ -3,16 +3,37 @@ module Spree
3
3
  class Approve
4
4
  prepend Spree::ServiceModule::Base
5
5
 
6
- def call(order:, approver: nil)
7
- changes = { considered_risky: false, approved_at: Time.current }
8
- if approver.present?
9
- changes[:approver_id] = approver.id
6
+ # Approves an order and records a Spree::OrderApproval history record.
7
+ #
8
+ # The legacy keyword `approver:` remains valid; new keywords (`level:`, `note:`)
9
+ # are additive and stored on the approval record.
10
+ #
11
+ # @param order [Spree::Order]
12
+ # @param approver [Object, nil] the user/admin who approved
13
+ # @param level [String, nil] approval level (used by 6.0 multi-level B2B flow)
14
+ # @param note [String, nil] staff-facing note
15
+ # @return [Spree::ServiceModule::Result]
16
+ def call(order:, approver: nil, level: nil, note: nil)
17
+ decided_at = Time.current
18
+
19
+ order.transaction do
20
+ order.approvals.create!(
21
+ status: 'approved',
22
+ level: level,
23
+ note: note,
24
+ approver: approver,
25
+ decided_at: decided_at,
26
+ created_at: decided_at
27
+ )
28
+
29
+ changes = { considered_risky: false, approved_at: decided_at }
30
+ changes[:approver_id] = approver.id if approver.present?
31
+ order.update_columns(changes)
10
32
  end
11
- order.update_columns(changes)
12
33
 
13
34
  order.publish_event('order.approved')
14
35
  success(order.reload)
15
- rescue ActiveRecord::Rollback, ActiveRecord::RecordInvalid, StateMachines::InvalidTransition
36
+ rescue ActiveRecord::RecordInvalid, StateMachines::InvalidTransition
16
37
  failure(order)
17
38
  end
18
39
  end
@@ -0,0 +1,29 @@
1
+ module Spree
2
+ module Orders
3
+ # Shared shipment-building step for admin order Create / Update.
4
+ #
5
+ # Rebuilds shipments from scratch (Stock::Coordinator), then layers in
6
+ # tax, costs, and free-shipping promotions so totals reflect delivery
7
+ # before payment is requested. Without this, draft orders would expose
8
+ # delivery_total: 0.0 until completion is attempted — which is too late.
9
+ #
10
+ # No-op when the order has no shipping address, no line items, or does
11
+ # not require delivery (digital orders, etc.).
12
+ class BuildShipments
13
+ prepend Spree::ServiceModule::Base
14
+
15
+ def call(order:)
16
+ return success(order) unless order.ship_address_id.present?
17
+ return success(order) unless order.line_items.any?
18
+ return success(order) unless order.delivery_required?
19
+
20
+ order.create_proposed_shipments
21
+ order.create_shipment_tax_charge!
22
+ order.set_shipments_cost
23
+ order.apply_free_shipping_promotions
24
+
25
+ success(order)
26
+ end
27
+ end
28
+ end
29
+ end