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
@@ -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
@@ -3,19 +3,50 @@ module Spree
3
3
  class Cancel
4
4
  prepend Spree::ServiceModule::Base
5
5
 
6
- def call(order:, canceler: nil, canceled_at: nil)
6
+ DEFAULT_REASON = 'other'.freeze
7
+
8
+ # Cancels an order and records a Spree::OrderCancellation history record.
9
+ # Legacy `canceler:` and `canceled_at:` remain valid; new keywords are additive.
10
+ #
11
+ # @param order [Spree::Order]
12
+ # @param canceler [Object, nil] the user/admin who initiated the cancellation
13
+ # @param canceled_at [Time, nil] timestamp (defaults to Time.current)
14
+ # @param reason [String] one of Spree::OrderCancellation::REASONS
15
+ # @param note [String, nil] staff-facing note
16
+ # @param restock_items [Boolean] whether to return inventory
17
+ # @param refund_payments [Boolean] whether to refund captured payments
18
+ # @param refund_amount [BigDecimal, Numeric, nil] amount to refund;
19
+ # when refund_payments is true and this is nil, defaults to order.payment_total
20
+ # @param notify_customer [Boolean] hint for subscribers
21
+ # @return [Spree::ServiceModule::Result]
22
+ def call(order:, canceler: nil, canceled_at: nil,
23
+ reason: DEFAULT_REASON, note: nil,
24
+ restock_items: false, refund_payments: false, refund_amount: nil,
25
+ notify_customer: false)
7
26
  canceled_at ||= Time.current
27
+ refund_amount ||= order.payment_total if refund_payments
8
28
 
9
29
  order.transaction do
30
+ order.cancellations.create!(
31
+ reason: reason,
32
+ note: note,
33
+ restock_items: restock_items,
34
+ refund_payments: refund_payments,
35
+ refund_amount: refund_amount,
36
+ notify_customer: notify_customer,
37
+ canceled_by: canceler,
38
+ created_at: canceled_at
39
+ )
40
+
10
41
  changes = { canceled_at: canceled_at }
11
42
  changes[:canceler_id] = canceler.id if canceler.present?
12
43
  order.update_columns(changes)
13
44
  order.cancel!
14
45
  end
15
46
 
16
- order.publish_event('order.canceled')
47
+ order.publish_event('order.canceled', order.event_payload.merge(notify_customer: notify_customer))
17
48
  success(order.reload)
18
- rescue ActiveRecord::Rollback, ActiveRecord::RecordInvalid, StateMachines::InvalidTransition
49
+ rescue ActiveRecord::RecordInvalid, StateMachines::InvalidTransition
19
50
  failure(order)
20
51
  end
21
52
  end
@@ -0,0 +1,53 @@
1
+ module Spree
2
+ module Orders
3
+ # Admin-side order completion.
4
+ #
5
+ # Distinct from Spree::Carts::Complete (storefront checkout). Callers must
6
+ # wrap invocation in Spree::Api::V3::OrderLock#with_order_lock — this
7
+ # service does not lock the row itself.
8
+ #
9
+ # @param order [Spree::Order]
10
+ # @param payment_pending [Boolean] if true, completes the order without
11
+ # processing payments. Order is placed but `payment_status` may be
12
+ # 'balance_due'. Useful for B2B / invoice-later flows.
13
+ # @param notify_customer [Boolean] if true, the customer receives the
14
+ # standard order confirmation email. Defaults to false — admin orders
15
+ # complete silently unless explicitly opted in.
16
+ # @return [Spree::ServiceModule::Result]
17
+ class Complete
18
+ prepend Spree::ServiceModule::Base
19
+
20
+ def call(order:, payment_pending: false, notify_customer: false)
21
+ order.notify_customer = notify_customer
22
+
23
+ return success(order) if order.completed?
24
+ return failure(order, 'Order is canceled') if order.canceled?
25
+
26
+ process_payments!(order) if order.payment_required? && !payment_pending
27
+
28
+ return failure(order, order.errors.full_messages.to_sentence) if order.errors.any?
29
+
30
+ advance_to_complete!(order)
31
+
32
+ if order.reload.complete?
33
+ success(order)
34
+ else
35
+ failure(order, order.errors.full_messages.to_sentence.presence || 'Could not complete order')
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def process_payments!(order)
42
+ return if order.payment_total >= order.total
43
+ return if order.payments.valid.any?(&:completed?) && order.unprocessed_payments.empty?
44
+
45
+ order.process_payments!
46
+ end
47
+
48
+ def advance_to_complete!(order)
49
+ order.next until order.complete? || order.errors.present?
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,156 @@
1
+ module Spree
2
+ module Orders
3
+ # Admin-side order creation. One-shot: customer, items, addresses, currency,
4
+ # market, locale, notes, metadata, and a coupon code in a single call.
5
+ # Invalid coupons are non-fatal — the order is created and `result.value`
6
+ # carries `discount_application_errors`.
7
+ #
8
+ # Standalone from Spree::Carts::Create (storefront). Admin-created orders
9
+ # are first-class Spree::Order records (status: 'draft') in 5.x and remain
10
+ # so in 6.0 — Spree::Cart in 6.0 is storefront-only.
11
+ class Create
12
+ prepend Spree::ServiceModule::Base
13
+
14
+ attr_reader :discount_application_errors
15
+
16
+ # @param store [Spree::Store]
17
+ # @param user [Object, nil] resolved customer (Spree.user_class instance)
18
+ # @param params [Hash] order params (see admin API docs)
19
+ # @return [Spree::ServiceModule::Result]
20
+ def call(store:, user: nil, params: {})
21
+ @store = store
22
+ @user = user
23
+ @params = params.to_h.deep_symbolize_keys
24
+ @discount_application_errors = []
25
+
26
+ return failure(:store_is_required) if store.nil?
27
+
28
+ order = nil
29
+ ApplicationRecord.transaction do
30
+ order = build_order
31
+ assign_addresses(order)
32
+ order.tags = @params[:tags] if @params[:tags]
33
+ order.save!
34
+
35
+ add_items(order) if @params[:items].present?
36
+ build_shipments(order)
37
+ apply_coupon(order) if @params[:coupon_code].present?
38
+ order.update_with_updater!
39
+ end
40
+
41
+ success(order.reload)
42
+ rescue ActiveRecord::RecordInvalid => e
43
+ failure(e.record, e.record.errors.full_messages.to_sentence)
44
+ end
45
+
46
+ private
47
+
48
+ def build_order
49
+ attrs = {
50
+ user: @user,
51
+ email: @params[:email] || @user&.email,
52
+ currency: @params[:currency].presence&.upcase || @store.default_currency,
53
+ locale: @params[:locale] || Spree::Current.locale,
54
+ customer_note: @params[:customer_note],
55
+ internal_note: @params[:internal_note],
56
+ metadata: @params[:metadata].to_h,
57
+ token: Spree::GenerateToken.new.call(Spree::Order),
58
+ status: 'draft'
59
+ }
60
+
61
+ attrs[:market] = resolve_market if @params[:market_id].present?
62
+ attrs[:channel] = resolve_channel if @params[:channel_id].present?
63
+ attrs[:preferred_stock_location] = resolve_preferred_stock_location if @params[:preferred_stock_location_id].present?
64
+ attrs.compact_blank!
65
+
66
+ @store.orders.new(attrs)
67
+ end
68
+
69
+ def resolve_market
70
+ @store.markets.find_by_param!(@params[:market_id])
71
+ end
72
+
73
+ def resolve_channel
74
+ @store.channels.find_by_param!(@params[:channel_id])
75
+ end
76
+
77
+ def resolve_preferred_stock_location
78
+ Spree::StockLocation.for_store(@store).find_by_param!(@params[:preferred_stock_location_id])
79
+ end
80
+
81
+ def assign_addresses(order)
82
+ if @params[:use_customer_default_address] && @user
83
+ @user.association(:bill_address).load_target
84
+ @user.association(:ship_address).load_target
85
+ order.bill_address = @user.bill_address&.dup
86
+ order.ship_address = @user.ship_address&.dup
87
+ end
88
+
89
+ assign_address(order, :ship_address, @params[:shipping_address_id], @params[:shipping_address])
90
+ assign_address(order, :bill_address, @params[:billing_address_id], @params[:billing_address])
91
+ end
92
+
93
+ def assign_address(order, association, address_id, address_attrs)
94
+ if address_id.present?
95
+ address = resolve_user_address(address_id)
96
+ order.public_send(:"#{association}_id=", address.id) if address
97
+ elsif address_attrs.present?
98
+ order.public_send(:"#{association}_attributes=", address_attrs)
99
+ end
100
+ end
101
+
102
+ def resolve_user_address(address_id)
103
+ return unless @user
104
+
105
+ @user.addresses.find_by_param(address_id)
106
+ end
107
+
108
+ def add_items(order)
109
+ result = Spree::Orders::UpsertItems.call(order: order, items: @params[:items])
110
+ return if result.success?
111
+
112
+ propagate_step_failure!(order, result, fallback: 'Failed to add items to order')
113
+ end
114
+
115
+ def build_shipments(order)
116
+ result = Spree::Orders::BuildShipments.call(order: order)
117
+ return if result.success?
118
+
119
+ propagate_step_failure!(order, result, fallback: 'Failed to build shipments')
120
+ end
121
+
122
+ # Surface the failing record's errors on the order so the API response
123
+ # carries an actionable message instead of an empty +processing_error+.
124
+ # Falls back to a static message when neither the record nor the result
125
+ # carry one — better than raising +RecordInvalid+ with an empty errors
126
+ # collection.
127
+ def propagate_step_failure!(order, result, fallback:)
128
+ record = result.value
129
+ if record.respond_to?(:errors) && record.errors.any?
130
+ record.errors.full_messages.each { |msg| order.errors.add(:base, msg) }
131
+ elsif result.error.to_s.present?
132
+ order.errors.add(:base, result.error.to_s)
133
+ else
134
+ order.errors.add(:base, fallback)
135
+ end
136
+ raise ActiveRecord::RecordInvalid, order
137
+ end
138
+
139
+ def apply_coupon(order)
140
+ order.coupon_code = @params[:coupon_code]
141
+ handler = Spree::PromotionHandler::Coupon.new(order).apply
142
+
143
+ if handler.successful?
144
+ order.save!
145
+ else
146
+ @discount_application_errors << {
147
+ code: handler.status_code,
148
+ message: handler.error,
149
+ coupon_code: @params[:coupon_code]
150
+ }
151
+ order.coupon_code = nil
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,51 @@
1
+ module Spree
2
+ module Orders
3
+ # Admin-side order update.
4
+ #
5
+ # Updates Order attributes plus optional line items via a flat `items: [...]`
6
+ # array (matches POST shape and Store API convention). Standalone from
7
+ # Spree::Carts::Update (storefront).
8
+ class Update
9
+ prepend Spree::ServiceModule::Base
10
+
11
+ def call(order:, params: {})
12
+ @order = order
13
+ @params = params.to_h.deep_symbolize_keys
14
+
15
+ items_param = @params.delete(:items)
16
+
17
+ ApplicationRecord.transaction do
18
+ ship_address_id_before = @order.ship_address_id
19
+
20
+ if @order.update(@params)
21
+ process_items(items_param) if items_param
22
+ else
23
+ return failure(@order, @order.errors.full_messages.to_sentence)
24
+ end
25
+
26
+ if items_param || @order.ship_address_id != ship_address_id_before
27
+ build_shipments
28
+ end
29
+
30
+ @order.update_with_updater!
31
+ end
32
+
33
+ success(@order.reload)
34
+ rescue ActiveRecord::RecordInvalid => e
35
+ failure(e.record, e.record.errors.full_messages.to_sentence)
36
+ end
37
+
38
+ private
39
+
40
+ def process_items(items)
41
+ result = Spree::Orders::UpsertItems.call(order: @order, items: items)
42
+ raise ActiveRecord::RecordInvalid, @order if result.failure?
43
+ end
44
+
45
+ def build_shipments
46
+ result = Spree::Orders::BuildShipments.call(order: @order)
47
+ raise ActiveRecord::RecordInvalid, @order if result.failure?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,70 @@
1
+ module Spree
2
+ module Orders
3
+ # Bulk upsert line items on an order. Mirrors Spree::Carts::UpsertItems
4
+ # but is admin/order-side (separate from the cart pipeline per the 6.0
5
+ # cart/order split — see docs/plans/6.0-cart-order-split.md).
6
+ #
7
+ # For each entry in +items+:
8
+ # - If a line item for the variant already exists -> sets its quantity
9
+ # - If no line item exists -> creates one with the given quantity
10
+ #
11
+ # Order totals are NOT recalculated here. Callers (Spree::Orders::Create
12
+ # and Spree::Orders::Update) are responsible for running shipment
13
+ # rebuilding and a final `order.update_with_updater!` once their
14
+ # full pipeline (items, shipments, coupons) has run.
15
+ class UpsertItems
16
+ prepend Spree::ServiceModule::Base
17
+
18
+ def call(order:, items:)
19
+ items = Array(items)
20
+ return success(order) if items.empty?
21
+
22
+ store = order.store || Spree::Current.store
23
+
24
+ ApplicationRecord.transaction do
25
+ items.each do |item_params|
26
+ item_params = item_params.to_h.deep_symbolize_keys
27
+ variant = resolve_variant(store, item_params[:variant_id])
28
+ next unless variant
29
+
30
+ quantity = (item_params[:quantity] || 1).to_i
31
+ next if quantity <= 0
32
+
33
+ return failure(variant, "#{variant.name} is not available in #{order.currency}") if variant.amount_in(order.currency).nil?
34
+
35
+ line_item = Spree.line_item_by_variant_finder.new.execute(order: order, variant: variant)
36
+
37
+ if line_item
38
+ line_item.quantity = quantity
39
+ line_item.metadata = line_item.metadata.merge(item_params[:metadata].to_h) if item_params[:metadata].present?
40
+ else
41
+ line_item = order.line_items.new(quantity: quantity, variant: variant, options: { currency: order.currency })
42
+ line_item.metadata = item_params[:metadata].to_h if item_params[:metadata].present?
43
+ end
44
+
45
+ return failure(line_item) unless line_item.save
46
+ end
47
+ end
48
+
49
+ success(order)
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_variant(store, variant_id)
55
+ return nil if variant_id.blank?
56
+
57
+ variant = store.variants.find_by_param(variant_id)
58
+
59
+ raise ActiveRecord::RecordNotFound.new(
60
+ "Variant '#{variant_id}' not found in this store",
61
+ 'Spree::Variant',
62
+ 'id',
63
+ variant_id
64
+ ) unless variant
65
+
66
+ variant
67
+ end
68
+ end
69
+ end
70
+ end