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
@@ -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
@@ -28,6 +28,8 @@ module Spree
28
28
 
29
29
  private
30
30
 
31
+ # `Spree::Payment#confirm!` honors the payment method's `auto_capture?` setting:
32
+ # auto_capture → complete! + capture_event; otherwise → pend! (auth-only, payment_state=balance_due).
31
33
  def handle_success(payment_session, order, metadata)
32
34
  order.with_lock do
33
35
  # Idempotency: if the session was already completed (by the API
@@ -36,19 +38,10 @@ module Spree
36
38
  return success(payment_session)
37
39
  end
38
40
 
39
- # Ensure payment record exists
40
41
  payment = payment_session.find_or_create_payment!(metadata)
41
-
42
- # Mark payment as completed — the webhook confirms the gateway processed it
43
- if payment.present? && !payment.completed?
44
- payment.started_processing! if payment.checkout?
45
- payment.complete! if payment.can_complete?
46
- end
47
-
48
- # Mark session as completed
42
+ payment.confirm! if payment.present? && !payment.completed?
49
43
  payment_session.complete if payment_session.can_complete?
50
44
 
51
- # Complete order if not already done
52
45
  unless order.reload.completed?
53
46
  Spree::Dependencies.carts_complete_service.constantize.call(cart: order)
54
47
  end
@@ -0,0 +1,201 @@
1
+ module Spree
2
+ module Prices
3
+ # Bulk-writes Spree::Price rows and sweeps stale placeholder rows in
4
+ # one transaction.
5
+ #
6
+ # `spree_prices` is guarded by two partial unique indexes on PG/SQLite
7
+ # (collapsed to one composite index on MySQL):
8
+ # - base prices (price_list_id IS NULL): unique on (variant_id, currency)
9
+ # - overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)
10
+ # A single `upsert_all` can only target one index, so rows ship in two
11
+ # batches — base vs override — each routed to the correct ON CONFLICT.
12
+ #
13
+ # Both indexes are also partial on `amount IS NOT NULL`, so `upsert_all`
14
+ # can't see placeholder rows (amount IS NULL) as conflict targets —
15
+ # filling in a placeholder via upsert inserts a sibling row instead of
16
+ # updating. The post-write sweep removes those.
17
+ class BulkUpsert
18
+ prepend Spree::ServiceModule::Base
19
+
20
+ # Two partial unique indexes guard `spree_prices` on PG/SQLite:
21
+ # - base prices (price_list_id IS NULL): unique on (variant_id, currency)
22
+ # - overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)
23
+ # A single `upsert_all` can only target one index, so base-price rows
24
+ # and override rows ship in separate batches.
25
+ BASE_UNIQUE_BY = %i[variant_id currency].freeze
26
+ OVERRIDE_UNIQUE_BY = %i[variant_id currency price_list_id].freeze
27
+
28
+ # @param rows [Array<Hash>] each row must carry
29
+ # `variant_id`, `currency`, and `amount`; `price_list_id` and
30
+ # `compare_at_amount` are optional. A blank `amount` is treated
31
+ # as "clear this price."
32
+ # @return [Spree::ServiceModule::Result] success carries
33
+ # `{ price_count: N }` — the number of rows passed to
34
+ # `upsert_all`.
35
+ def call(rows:)
36
+ rows = Array(rows).map { |r| r.with_indifferent_access }
37
+ keyed = rows.select { |r| r[:variant_id].present? && r[:currency].present? }
38
+ # PG rejects an upsert with two rows hitting the same unique-key
39
+ # triple in one statement ("ON CONFLICT DO UPDATE command cannot
40
+ # affect row a second time"). Last-write-wins: keep the last
41
+ # occurrence of each triple.
42
+ deduped = keyed.reverse.uniq { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }.reverse
43
+ upsert_rows, clear_rows = deduped.partition { |r| r[:amount].present? }
44
+
45
+ payload = build_payload(upsert_rows)
46
+ affected_keys = deduped.map { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }
47
+
48
+ return success(price_count: 0) if affected_keys.empty?
49
+
50
+ base_rows, override_rows = payload.partition { |r| r[:price_list_id].nil? }
51
+
52
+ Spree::Price.transaction do
53
+ # MySQL treats NULL values as distinct in unique indexes, so
54
+ # `ON DUPLICATE KEY UPDATE` never fires for base prices —
55
+ # `upsert_all` would silently insert a sibling row. Route base
56
+ # rows through a SELECT-then-UPDATE/INSERT path on MySQL only.
57
+ if base_rows.any? && mysql?
58
+ upsert_base_rows_for_mysql(base_rows)
59
+ else
60
+ upsert_batch(base_rows, BASE_UNIQUE_BY)
61
+ end
62
+ upsert_batch(override_rows, OVERRIDE_UNIQUE_BY)
63
+ sweep(affected_keys, clear_rows)
64
+ # `upsert_all` and `delete_all` both skip AR callbacks, so the
65
+ # `Price -> Variant -> Product` `touch:` chain never fires —
66
+ # downstream caches (`cache_key_with_version`) would stay stale.
67
+ # Re-trigger the chain with one `.touch` per affected variant.
68
+ touch_variants(affected_keys.map(&:first).uniq)
69
+ end
70
+
71
+ success(price_count: payload.length)
72
+ end
73
+
74
+ private
75
+
76
+ # `update_only` lists only domain columns. Rails adds `updated_at`
77
+ # automatically (when `record_timestamps` is on, which it is for
78
+ # `Spree::Price`); listing it explicitly here produces
79
+ # `SET updated_at = …, updated_at = …` on PG and the statement fails
80
+ # with "multiple assignments to same column".
81
+ def upsert_batch(rows, unique_by)
82
+ return if rows.empty?
83
+
84
+ Spree::Price.upsert_all(
85
+ rows,
86
+ update_only: %i[amount compare_at_amount],
87
+ **upsert_opts(unique_by)
88
+ )
89
+ end
90
+
91
+ def build_payload(rows)
92
+ now = Time.current
93
+ rows.map do |row|
94
+ {
95
+ variant_id: row[:variant_id],
96
+ currency: row[:currency],
97
+ price_list_id: row[:price_list_id],
98
+ amount: parse_amount(row[:amount]),
99
+ compare_at_amount: parse_amount(row[:compare_at_amount]),
100
+ created_at: now,
101
+ updated_at: now
102
+ }
103
+ end
104
+ end
105
+
106
+ # Parses locale-aware decimal input ("1.234,56" in DE, "1,234.56"
107
+ # in en-US). Numeric values pass through; blank values become nil.
108
+ def parse_amount(value)
109
+ return nil if value.blank?
110
+ return value if value.is_a?(Numeric)
111
+
112
+ Spree::LocalizedNumber.parse(value)
113
+ end
114
+
115
+ def sweep(affected_keys, clear_rows)
116
+ cleared_keys = clear_rows.map { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }.to_set
117
+ affected_set = affected_keys.to_set
118
+
119
+ candidates = Spree::Price
120
+ .where(
121
+ variant_id: affected_keys.map(&:first).uniq,
122
+ currency: affected_keys.map { |k| k[1] }.uniq,
123
+ price_list_id: affected_keys.map(&:last).uniq
124
+ )
125
+ .pluck(:id, :variant_id, :currency, :price_list_id, :amount)
126
+
127
+ doomed_ids = candidates.filter_map do |id, variant_id, currency, price_list_id, amount|
128
+ key = [variant_id, currency, price_list_id]
129
+ next unless affected_set.include?(key)
130
+ next id if amount.nil?
131
+ next id if cleared_keys.include?(key)
132
+
133
+ nil
134
+ end
135
+
136
+ Spree::Price.where(id: doomed_ids).delete_all if doomed_ids.any?
137
+ end
138
+
139
+ # Bumps `updated_at` on the affected variants and their parent
140
+ # products to invalidate `cache_key_with_version`-based caches —
141
+ # `upsert_all` and `delete_all` skip the `Price -> Variant` and
142
+ # `Variant -> Product` `touch:` chains otherwise.
143
+ #
144
+ # We deliberately bypass AR callbacks: invoking `Variant#touch`
145
+ # would fire `after_commit :remove_prices_from_master_variant`,
146
+ # which `delete_all`s the master's prices whenever a non-master
147
+ # sibling has any prices — wiping exactly the rows the bulk
148
+ # upsert just persisted on a freshly-created list. `touch_all`
149
+ # gives us the cache bust without that side effect.
150
+ def touch_variants(variant_ids)
151
+ return if variant_ids.empty?
152
+
153
+ variants = Spree::Variant.where(id: variant_ids)
154
+ product_ids = variants.pluck(:product_id).uniq
155
+ variants.touch_all
156
+ Spree::Product.where(id: product_ids).touch_all if product_ids.any?
157
+ end
158
+
159
+ # MySQL infers conflict targets from its own unique indexes and
160
+ # rejects an explicit `unique_by`.
161
+ def upsert_opts(unique_by)
162
+ return {} if mysql?
163
+
164
+ { unique_by: unique_by }
165
+ end
166
+
167
+ def mysql?
168
+ ActiveRecord::Base.connection.adapter_name == 'Mysql2'
169
+ end
170
+
171
+ # MySQL-only path: NULLs are distinct in unique indexes, so
172
+ # `(variant_id, currency, NULL)` doesn't conflict with another
173
+ # `(variant_id, currency, NULL)` — `upsert_all` would insert a
174
+ # sibling instead of updating. Look up existing base rows first,
175
+ # update them one by one, and `insert_all` the rest.
176
+ def upsert_base_rows_for_mysql(rows)
177
+ rows_by_key = rows.index_by { |r| [r[:variant_id], r[:currency]] }
178
+
179
+ Spree::Price.where(
180
+ variant_id: rows.map { |r| r[:variant_id] }.uniq,
181
+ currency: rows.map { |r| r[:currency] }.uniq,
182
+ price_list_id: nil
183
+ ).find_each do |price|
184
+ # The `IN (...)` query can return cross-pairs (e.g. `v=1,c=EUR`
185
+ # exists in the DB even though the caller only passed `v=1,c=USD`
186
+ # and `v=2,c=EUR`). Skip rows the caller didn't request.
187
+ row = rows_by_key.delete([price.variant_id, price.currency])
188
+ next unless row
189
+
190
+ price.update_columns(
191
+ amount: row[:amount],
192
+ compare_at_amount: row[:compare_at_amount],
193
+ updated_at: row[:updated_at]
194
+ )
195
+ end
196
+
197
+ Spree::Price.insert_all(rows_by_key.values) if rows_by_key.any?
198
+ end
199
+ end
200
+ end
201
+ end
@@ -28,7 +28,7 @@ module Spree
28
28
  new_product.status = :draft
29
29
  new_product.name = "COPY OF #{product.name}"
30
30
  new_product.taxons = product.taxons
31
- new_product.stores = product.stores
31
+ new_product.channels = product.channels
32
32
  new_product.created_at = nil
33
33
  new_product.deleted_at = nil
34
34
  new_product.updated_at = nil
@@ -67,14 +67,8 @@ module Spree
67
67
  end
68
68
  end
69
69
 
70
- # ensure there is at least one store
71
- params[:store_ids] = [store.id] if params[:store_ids].blank?
72
-
73
- # Preserve taxon associations from other stores
74
- # Only merge taxon_ids from other stores if taxon_ids are being updated
75
- if params.key?(:taxon_ids)
76
- params[:taxon_ids] = merge_taxons_from_other_stores(params[:taxon_ids])
77
- end
70
+ # ensure the product is owned by a store
71
+ params[:store_id] = store.id if params[:store_id].blank? && product.store_id.blank?
78
72
 
79
73
  # Add empty list for option_type_ids and mark variants as removed if there are no variants and options
80
74
  if params[:variants_attributes].blank? && variants_to_remove.any? && !params.key?(:option_type_ids)
@@ -186,28 +180,6 @@ module Spree
186
180
  attributes
187
181
  end
188
182
 
189
- # Merges taxon IDs from other stores with submitted taxon IDs from current store.
190
- #
191
- # This prevents the loss of taxon associations from other stores when a product
192
- # is edited in one store. Each store's taxonomy is independent, so editing
193
- # categories in Store A should not affect categories in Store B.
194
- #
195
- # @param submitted_taxon_ids [Array<String>] Taxon IDs from the current store
196
- # @return [Array<String>] Combined unique taxon IDs
197
- def merge_taxons_from_other_stores(submitted_taxon_ids)
198
- return submitted_taxon_ids if product.new_record?
199
-
200
- # Get taxon IDs from other stores that should be preserved
201
- other_stores_taxon_ids = product.taxons
202
- .joins(:taxonomy)
203
- .where.not(spree_taxonomies: { store_id: store.id })
204
- .pluck(:id)
205
- .map(&:to_s)
206
-
207
- # Merge with submitted taxon IDs from current store and remove duplicates
208
- (submitted_taxon_ids + other_stores_taxon_ids).uniq
209
- end
210
-
211
183
  def update_option_value_variants(option_value_params, existing_variant)
212
184
  return {} unless option_value_params.present?
213
185
  return {} unless can_manage_option_types?