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
@@ -12,6 +12,15 @@ module Spree
12
12
 
13
13
  scope :of_type, ->(t) { where(type: t) }
14
14
 
15
+ # Per-subclass permitted attributes beyond `type` and `preferences`.
16
+ # Override in STI subclasses that accept nested attributes (e.g.
17
+ # CreateLineItems needs `promotion_action_line_items_attributes`,
18
+ # CreateAdjustment needs `calculator_type` + `calculator_attributes`).
19
+ # The Admin API merges these into its `params.permit(...)` allowlist.
20
+ def self.additional_permitted_attributes
21
+ []
22
+ end
23
+
15
24
  # This method should be overridden in subclass
16
25
  # Updates the state of the order or performs some other action depending on the subclass
17
26
  # options will contain the payload from the event that activated the promotion. This will include
@@ -27,25 +36,22 @@ module Spree
27
36
  type == 'Spree::Promotion::Actions::FreeShipping'
28
37
  end
29
38
 
30
- # Returns the human name of the promotion action
31
- #
32
- # @return [String] eg. Free Shipping
33
- def human_name
34
- Spree.t("promotion_action_types.#{key}.name")
39
+ def self.human_name
40
+ Spree.t("promotion_action_types.#{api_type}.name", default: api_type.titleize)
35
41
  end
36
42
 
37
- # Returns the human description of the promotion action
38
- #
39
- # @return [String]
40
- def human_description
41
- Spree.t("promotion_action_types.#{key}.description")
43
+ def self.human_description
44
+ Spree.t("promotion_action_types.#{api_type}.description", default: '')
42
45
  end
43
46
 
47
+ def human_name = self.class.human_name
48
+ def human_description = self.class.human_description
49
+
44
50
  # Returns the key of the promotion action
45
51
  #
46
52
  # @return [String] eg. free_shipping
47
53
  def key
48
- type.demodulize.underscore
54
+ self.class.api_type
49
55
  end
50
56
 
51
57
  protected
@@ -10,7 +10,15 @@ module Spree
10
10
  scope :of_type, ->(t) { where(type: t) }
11
11
 
12
12
  validates :promotion, presence: true
13
- validate :unique_per_promotion, on: :create
13
+ validates :type, uniqueness: { scope: [:promotion_id, *spree_base_uniqueness_scope] }
14
+
15
+ # Per-subclass permitted attributes beyond `type` and `preferences`.
16
+ # Override in STI subclasses that accept association IDs (e.g.
17
+ # Rules::Product needs `product_ids`). The Admin API merges these
18
+ # into its `params.permit(...)` allowlist.
19
+ def self.additional_permitted_attributes
20
+ []
21
+ end
14
22
 
15
23
  def self.for(promotable)
16
24
  all.select { |rule| rule.applicable?(promotable) }
@@ -34,35 +42,26 @@ module Spree
34
42
  @eligibility_errors ||= ActiveModel::Errors.new(self)
35
43
  end
36
44
 
37
- # Returns the human name of the promotion rule
38
- #
39
- # @return [String] eg. Currency
40
- def human_name
41
- Spree.t("promotion_rule_types.#{key}.name")
45
+ def self.human_name
46
+ Spree.t("promotion_rule_types.#{api_type}.name", default: api_type.titleize)
42
47
  end
43
48
 
44
- # Returns the human description of the promotion rule
45
- #
46
- # @return [String]
47
- def human_description
48
- Spree.t("promotion_rule_types.#{key}.description")
49
+ def self.human_description
50
+ Spree.t("promotion_rule_types.#{api_type}.description", default: '')
49
51
  end
50
52
 
53
+ def human_name = self.class.human_name
54
+ def human_description = self.class.human_description
55
+
51
56
  # Returns the key of the promotion rule
52
57
  #
53
58
  # @return [String] eg. currency
54
59
  def key
55
- type.demodulize.underscore
60
+ self.class.api_type
56
61
  end
57
62
 
58
63
  private
59
64
 
60
- def unique_per_promotion
61
- if Spree::PromotionRule.exists?(promotion_id: promotion_id, type: self.class.name)
62
- errors.add(:base, 'Promotion already contains this rule type')
63
- end
64
- end
65
-
66
65
  def eligibility_error_message(key, options = {})
67
66
  Spree.t(key, Hash[scope: [:eligibility_errors, :messages]].merge(options))
68
67
  end
@@ -233,7 +233,7 @@ module Spree
233
233
  end
234
234
 
235
235
  def filterable_attributes
236
- %w[product_id status in_stock store_ids locale currency discontinue_on price category_ids tags option_value_ids]
236
+ %w[product_id status in_stock store_ids channel_ids locale currency available_on discontinue_on price category_ids tags option_value_ids]
237
237
  end
238
238
 
239
239
  def sortable_attributes
@@ -259,12 +259,22 @@ module Spree
259
259
  # System scoping — always applied. Rarely overridden.
260
260
  # Mirrors the AR scope: store.products.active(currency) with locale.
261
261
  def system_filter_conditions
262
+ now = Time.current
262
263
  conditions = []
263
264
  conditions << "store_ids = '#{store.id}'"
265
+ conditions << "channel_ids = '#{Spree::Current.channel.id}'" if Spree::Current.channel
264
266
  conditions << "status = 'active'"
265
267
  conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
266
268
  conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
267
- conditions << "(discontinue_on = 0 OR discontinue_on > #{Time.current.to_i})"
269
+ # Exclude future-dated products mirrors +Product.available(Time.current)+.
270
+ # ISO 8601 strings sort lexicographically in chronological order, so the
271
+ # string compare is sound. +NOT EXISTS+ catches docs indexed before this
272
+ # attribute was emitted (legacy indexes), +IS NULL+ catches docs where
273
+ # the field was emitted as explicit null, and the +<=+ clause filters
274
+ # the remaining future-dated docs — so the upgrade is non-breaking and
275
+ # no reindex is required.
276
+ conditions << "(available_on NOT EXISTS OR available_on IS NULL OR available_on <= '#{now.iso8601}')"
277
+ conditions << "(discontinue_on = 0 OR discontinue_on > #{now.to_i})"
268
278
  conditions
269
279
  end
270
280
 
@@ -272,16 +272,22 @@ module Spree
272
272
  def manifest
273
273
  # Grouping by the ID means that we don't have to call out to the association accessor
274
274
  # This makes the grouping by faster because it results in less SQL cache hits.
275
- inventory_units.group_by(&:variant_id).map do |_variant_id, units|
276
- units.group_by(&:line_item_id).map do |_line_item_id, units|
275
+ inventory_units.group_by(&:variant_id).flat_map do |_variant_id, units|
276
+ units.group_by(&:line_item_id).filter_map do |_line_item_id, units|
277
+ line_item = units.first.line_item
278
+ # Defensively skip orphaned inventory units (line item destroyed
279
+ # without cascading) so a single bad row doesn't crash callers that
280
+ # rely on line_item being present (item_cost, item_weight, the admin
281
+ # shipment manifest view, etc.).
282
+ next if line_item.nil?
283
+
277
284
  states = {}
278
285
  units.group_by(&:state).each { |state, iu| states[state] = iu.sum(&:quantity) }
279
286
 
280
- line_item = units.first.line_item
281
287
  variant = units.first.variant
282
288
  ManifestItem.new(line_item, variant, units.sum(&:quantity), states)
283
289
  end
284
- end.flatten
290
+ end
285
291
  end
286
292
 
287
293
  def process_order_payments
@@ -28,7 +28,7 @@ module Spree
28
28
  private
29
29
 
30
30
  def item_available?(line_item, quantity)
31
- Spree::Stock::Quantifier.new(line_item.variant).can_supply?(quantity)
31
+ Spree::Stock::Quantifier.new(line_item.variant, excluded_order: line_item.order).can_supply?(quantity)
32
32
  end
33
33
  end
34
34
  end
@@ -1,25 +1,89 @@
1
1
  module Spree
2
2
  module Stock
3
3
  class Quantifier
4
- attr_reader :variant, :stock_location
4
+ attr_reader :variant, :stock_location, :excluded_order
5
5
 
6
- def initialize(variant, stock_location = nil)
7
- @variant = variant
8
- @stock_location = stock_location
6
+ # @param excluded_order [Spree::Order, nil] when given, reservations
7
+ # belonging to this order are not counted against availability. Used
8
+ # when checking an order's own line items so the customer's own
9
+ # checkout hold doesn't make their item look out of stock.
10
+ def initialize(variant, stock_location = nil, excluded_order: nil)
11
+ @variant = variant
12
+ @stock_location = stock_location
13
+ @excluded_order = excluded_order
9
14
  end
10
15
 
16
+ # Units a customer can purchase right now: physical pool minus
17
+ # already-allocated units minus active checkout reservations. Clamped
18
+ # at zero so callers never see a negative count.
19
+ #
20
+ # Returns +BigDecimal::INFINITY+ when the variant does not track
21
+ # inventory (effectively unlimited supply).
22
+ #
23
+ # @return [Integer, BigDecimal] purchasable quantity, or +INFINITY+
11
24
  def total_on_hand
12
25
  @total_on_hand ||= if variant.should_track_inventory?
13
- if association_loaded?
14
- stock_items.sum(&:count_on_hand)
15
- else
16
- stock_items.sum(:count_on_hand)
17
- end
26
+ [available_stock - reserved_quantity, 0].max
18
27
  else
19
28
  BigDecimal::INFINITY
20
29
  end
21
30
  end
22
31
 
32
+ # Physical pool minus already-allocated units, summed across the
33
+ # variant's active stock items.
34
+ #
35
+ # In Spree 5.5 {Spree::StockItem#allocated_count} is a Ruby shim that
36
+ # always returns 0, so this equals +SUM(count_on_hand)+. In 6.0
37
+ # (Typed Stock Movements) +allocated_count+ becomes a real column and
38
+ # the SQL path subtracts it natively.
39
+ #
40
+ # @return [Integer] units available before checkout reservations
41
+ def available_stock
42
+ if association_loaded?
43
+ stock_items.sum(&:available_count)
44
+ elsif self.class.allocated_count_column?
45
+ stock_items.sum('count_on_hand - allocated_count')
46
+ else
47
+ stock_items.sum(:count_on_hand)
48
+ end
49
+ end
50
+
51
+ # Units currently held by active checkout reservations on the
52
+ # location-filtered stock items. Returns 0 when stock reservations
53
+ # are globally disabled.
54
+ #
55
+ # Reads through the same {#stock_items} collection as {#available_stock}
56
+ # so a per-location query (filtered by `stock_location`) only counts
57
+ # reservations that belong to those same stock items — otherwise a
58
+ # multi-location variant would subtract reservations from other
59
+ # warehouses.
60
+ #
61
+ # When +excluded_order+ is set, that order's own reservations are left
62
+ # out of the count so an order's own checkout hold doesn't count
63
+ # against the availability of its own line items.
64
+ #
65
+ # @return [Integer]
66
+ def reserved_quantity
67
+ return @reserved_quantity if defined?(@reserved_quantity)
68
+ return @reserved_quantity = 0 unless Spree::Config[:stock_reservations_enabled]
69
+ return @reserved_quantity = 0 if stock_items.blank?
70
+
71
+ excluded_order_id = excluded_order&.id
72
+
73
+ @reserved_quantity = if reservations_preloaded?
74
+ stock_items.sum do |si|
75
+ reservations = si.active_stock_reservations
76
+ reservations = reservations.reject { |r| r.order_id == excluded_order_id } if excluded_order_id
77
+ reservations.sum(&:quantity)
78
+ end
79
+ else
80
+ reservations = Spree::StockReservation.active.where(stock_item_id: stock_items.map(&:id))
81
+ reservations = reservations.where.not(order_id: excluded_order_id) if excluded_order_id
82
+ reservations.sum(:quantity)
83
+ end
84
+ end
85
+
86
+ # Check if any of variant stock items is backorderable
23
87
  def backorderable?
24
88
  @backorderable ||= stock_items.any?(&:backorderable)
25
89
  end
@@ -32,12 +96,28 @@ module Spree
32
96
  @stock_items ||= scope_to_location(variant.stock_items)
33
97
  end
34
98
 
99
+ # Memoized schema check so {#available_stock} doesn't introspect the
100
+ # column list on every call. Flips from false → true when 6.0 Typed
101
+ # Stock Movements adds the `allocated_count` column.
102
+ #
103
+ # @return [Boolean]
104
+ def self.allocated_count_column?
105
+ return @allocated_count_column if defined?(@allocated_count_column)
106
+
107
+ @allocated_count_column = Spree::StockItem.connection.column_exists?(:spree_stock_items, :allocated_count)
108
+ end
109
+
35
110
  private
36
111
 
37
112
  def association_loaded?
38
113
  variant.association(:stock_items).loaded?
39
114
  end
40
115
 
116
+ def reservations_preloaded?
117
+ association_loaded? &&
118
+ stock_items.all? { |si| si.association(:active_stock_reservations).loaded? }
119
+ end
120
+
41
121
  def scope_to_location(collection)
42
122
  if stock_location.blank?
43
123
  if association_loaded?
@@ -15,6 +15,8 @@ module Spree
15
15
  belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant'
16
16
  end
17
17
  has_many :stock_movements, inverse_of: :stock_item
18
+ has_many :stock_reservations, class_name: 'Spree::StockReservation', inverse_of: :stock_item, dependent: :destroy
19
+ has_many :active_stock_reservations, -> { active }, class_name: 'Spree::StockReservation', inverse_of: :stock_item
18
20
 
19
21
  validates :stock_location, :variant, presence: true
20
22
  validates :variant_id, uniqueness: { scope: :stock_location_id }, unless: :deleted_at
@@ -38,6 +40,18 @@ module Spree
38
40
 
39
41
  scope :with_active_stock_location, -> { joins(:stock_location).merge(Spree::StockLocation.active) }
40
42
 
43
+ # Stock items for products assigned to `store`. Walks
44
+ # `variant → product → stores`, dedups via `distinct` so a product
45
+ # in multiple stores doesn't double-count its stock items.
46
+ #
47
+ # Used by the admin API as the base scope (`StockItem.for_store`)
48
+ # so the controller can filter directly by stock_location/variant
49
+ # without inheriting `Spree::Store#stock_items`'s extra joins or
50
+ # the variant default ordering.
51
+ scope :for_store, ->(store) {
52
+ joins(variant: :product).where(spree_products: { store_id: store.id })
53
+ }
54
+
41
55
  def backordered_inventory_units
42
56
  Spree::InventoryUnit.backordered_for_stock_item(self)
43
57
  end
@@ -64,6 +78,28 @@ module Spree
64
78
  in_stock? || backorderable?
65
79
  end
66
80
 
81
+ # Units already allocated to pending shipments at this stock item.
82
+ #
83
+ # Always returns 0 in Spree 5.5. The 6.0 Typed Stock Movements plan
84
+ # (see docs/plans/6.0-typed-stock-movements.md) adds an indexed
85
+ # `allocated_count` column updated by typed movements (`allocated`,
86
+ # `released`, `shipped`); the Rails column accessor then takes
87
+ # precedence over this method automatically.
88
+ #
89
+ # @return [Integer]
90
+ def allocated_count
91
+ 0
92
+ end
93
+
94
+ # Physical stock minus allocated units at this stock item. Distinct from
95
+ # {Spree::Stock::Quantifier#available_stock}, which sums this across all
96
+ # stock items belonging to a variant.
97
+ #
98
+ # @return [Integer]
99
+ def available_count
100
+ count_on_hand - allocated_count
101
+ end
102
+
67
103
  def reduce_count_on_hand_to_zero
68
104
  set_count_on_hand(0) if count_on_hand > 0
69
105
  end
@@ -2,6 +2,15 @@ module Spree
2
2
  class StockLocation < Spree.base_class
3
3
  has_prefix_id :sloc # Spree-specific: stock location
4
4
 
5
+ # Categorizes the location. Open string — extensible by setting any value;
6
+ # KINDS lists the built-in options used by the admin UI dropdown.
7
+ KINDS = %w[warehouse store fulfillment_center].freeze
8
+
9
+ # Pickup stock policy: 'local' = only items physically at this location are
10
+ # collectable; 'any' = items can be transferred from other locations
11
+ # (ship-to-store). See docs/plans/6.0-fulfillment-and-delivery.md.
12
+ PICKUP_STOCK_POLICIES = %w[local any].freeze
13
+
5
14
  include Spree::UniqueName
6
15
  if defined?(Spree::Security::StockLocations)
7
16
  include Spree::Security::StockLocations
@@ -20,14 +29,41 @@ module Spree
20
29
  belongs_to :state, class_name: 'Spree::State', optional: true
21
30
  belongs_to :country, class_name: 'Spree::Country'
22
31
 
32
+ validates :kind, presence: true
33
+ validates :pickup_stock_policy, inclusion: { in: PICKUP_STOCK_POLICIES }
34
+ validates :pickup_ready_in_minutes,
35
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 },
36
+ allow_nil: true
37
+
38
+ self.whitelisted_ransackable_attributes = %w[
39
+ name active default kind pickup_enabled
40
+ country_id state_id created_at updated_at
41
+ ]
42
+
23
43
  scope :active, -> { where(active: true) }
44
+ scope :pickup_enabled, -> { where(pickup_enabled: true) }
24
45
  scope :order_default, -> { order(default: :desc, name: :asc) }
25
46
 
47
+ before_validation :normalize_country
48
+ before_validation :normalize_state
49
+
26
50
  after_create :create_stock_items, if: :propagate_all_variants?
27
51
  after_save :ensure_one_default
28
52
  after_update :conditional_touch_records
29
53
 
30
54
  delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true, allow_nil: true
55
+ delegate :abbr, to: :state, prefix: true, allow_nil: true
56
+
57
+ # Writer methods for API convenience — accept ISO/abbr codes instead of FK IDs.
58
+ # Mirrors Spree::Address: SDK clients use country_iso/state_abbr because
59
+ # Country/State don't expose prefixed IDs (their `iso` is the public handle).
60
+ def country_iso=(value)
61
+ @country_iso_input = value
62
+ end
63
+
64
+ def state_abbr=(value)
65
+ @state_abbr_input = value
66
+ end
31
67
 
32
68
  def state_text
33
69
  state.try(:abbr) || state.try(:name) || state_name
@@ -168,6 +204,22 @@ module Spree
168
204
 
169
205
  private
170
206
 
207
+ def normalize_country
208
+ iso = @country_iso_input
209
+ return if iso.blank?
210
+
211
+ self.country = Spree::Country.by_iso(iso)
212
+ @country_iso_input = nil
213
+ end
214
+
215
+ def normalize_state
216
+ abbr = @state_abbr_input
217
+ return if abbr.blank? || country.blank?
218
+
219
+ self.state = country.states.find_by(abbr: abbr)
220
+ @state_abbr_input = nil
221
+ end
222
+
171
223
  def create_stock_items
172
224
  Spree::StockLocations::StockItems::CreateJob.perform_later(self)
173
225
  end
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ class StockReservation < Spree.base_class
3
+ has_prefix_id :res
4
+
5
+ publishes_lifecycle_events
6
+
7
+ belongs_to :stock_item, class_name: 'Spree::StockItem', inverse_of: :stock_reservations
8
+ belongs_to :line_item, class_name: 'Spree::LineItem', inverse_of: :stock_reservations
9
+ belongs_to :order, class_name: 'Spree::Order', inverse_of: :stock_reservations
10
+
11
+ validates :stock_item, :line_item, :order, :quantity, :expires_at, presence: true
12
+ validates :quantity, numericality: { greater_than: 0, only_integer: true }, presence: true
13
+ validates :line_item_id, uniqueness: { scope: :stock_item_id }, presence: true
14
+
15
+ scope :active, -> { where('spree_stock_reservations.expires_at > ?', Time.current) }
16
+ scope :expired, -> { where('spree_stock_reservations.expires_at <= ?', Time.current) }
17
+ scope :for_order, ->(order) { where(order_id: order.id) }
18
+ scope :for_store, ->(store) {
19
+ joins(:order).where(spree_orders: { store_id: store.id })
20
+ }
21
+
22
+ self.whitelisted_ransackable_attributes = %w[stock_item_id line_item_id order_id quantity expires_at]
23
+ self.whitelisted_ransackable_associations = %w[stock_item line_item order]
24
+
25
+ def active?
26
+ expires_at > Time.current
27
+ end
28
+
29
+ # Resolves the reservation TTL: per-Store preference if set, otherwise
30
+ # the global Spree::Config[:default_stock_reservation_ttl_minutes]. Falls
31
+ # back to 10 minutes if both are unset (e.g. early-boot / fixture state).
32
+ def self.ttl_for(order)
33
+ minutes = order&.store&.preferred_stock_reservation_ttl_minutes
34
+ minutes = Spree::Config[:default_stock_reservation_ttl_minutes] if minutes.blank?
35
+ minutes.to_i.then { |m| m > 0 ? m : 10 }.minutes
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ module StockReservations
3
+ class InsufficientStockError < StandardError
4
+ attr_reader :line_item
5
+
6
+ def initialize(line_item, message)
7
+ @line_item = line_item
8
+ super(message)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -12,8 +12,10 @@ module Spree
12
12
  include Spree::Metadata
13
13
  include Spree::Stores::Setup
14
14
  include Spree::Stores::Markets
15
+ include Spree::Stores::Channels
15
16
  include Spree::Security::Stores if defined?(Spree::Security::Stores)
16
17
  include Spree::UserManagement
18
+ include Spree::OrderRouting::HasStrategyPreference
17
19
 
18
20
  #
19
21
  # Magic methods
@@ -46,6 +48,7 @@ module Spree
46
48
  # Checkout preferences
47
49
  preference :guest_checkout, :boolean, default: true
48
50
  preference :special_instructions_enabled, :boolean, default: false
51
+ preference :stock_reservation_ttl_minutes, :integer, default: 10
49
52
  # Address preferences
50
53
  preference :company_field_enabled, :boolean, default: false
51
54
  # digital assets preferences
@@ -54,6 +57,9 @@ module Spree
54
57
  preference :digital_asset_authorized_clicks, :integer, default: 5
55
58
  preference :digital_asset_authorized_days, :integer, default: 7
56
59
  preference :digital_asset_link_expire_time, :integer, default: 300
60
+ # Class name of the Spree::OrderRouting::Strategy::Base subclass that
61
+ # decides which StockLocation fulfills which items.
62
+ preference :order_routing_strategy, :string, default: 'Spree::OrderRouting::Strategy::Rules'
57
63
 
58
64
  #
59
65
  # Associations
@@ -71,10 +77,11 @@ module Spree
71
77
  has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod'
72
78
  has_many :payment_methods, through: :store_payment_methods, class_name: 'Spree::PaymentMethod'
73
79
 
74
- has_many :store_products, class_name: 'Spree::StoreProduct'
75
- has_many :products, through: :store_products, class_name: 'Spree::Product'
80
+ has_many :products, class_name: 'Spree::Product', dependent: :nullify
81
+ has_many :product_publications, through: :channels, source: :publications, class_name: 'Spree::ProductPublication'
76
82
  has_many :variants, through: :products, class_name: 'Spree::Variant', source: :variants_including_master
77
83
  has_many :stock_items, through: :variants, class_name: 'Spree::StockItem'
84
+ has_many :prices, through: :variants, class_name: 'Spree::Price'
78
85
  has_many :inventory_units, through: :variants, class_name: 'Spree::InventoryUnit'
79
86
  has_many :option_value_variants, through: :variants, class_name: 'Spree::OptionValueVariant'
80
87
  has_many :customer_returns, class_name: 'Spree::CustomerReturn', inverse_of: :store
@@ -108,6 +115,9 @@ module Spree
108
115
  has_many :webhook_endpoints, class_name: 'Spree::WebhookEndpoint', dependent: :destroy, inverse_of: :store
109
116
  has_many :webhook_deliveries, through: :webhook_endpoints, class_name: 'Spree::WebhookDelivery'
110
117
 
118
+ has_many :channels, class_name: 'Spree::Channel', dependent: :destroy
119
+ has_many :order_routing_rules, through: :channels, class_name: 'Spree::OrderRoutingRule'
120
+
111
121
  has_many :customer_groups, class_name: 'Spree::CustomerGroup', dependent: :destroy, inverse_of: :store
112
122
 
113
123
  has_many :api_keys, class_name: 'Spree::ApiKey', dependent: :destroy
@@ -121,6 +131,7 @@ module Spree
121
131
  end
122
132
  validates :preferred_digital_asset_authorized_clicks, numericality: { only_integer: true, greater_than: 0 }
123
133
  validates :preferred_digital_asset_authorized_days, numericality: { only_integer: true, greater_than: 0 }
134
+ validates :preferred_stock_reservation_ttl_minutes, numericality: { only_integer: true, greater_than: 0 }
124
135
  validates :mail_from_address, email: { allow_blank: false }
125
136
  # FIXME: we should remove this condition in v5
126
137
  if !ENV['SPREE_DISABLE_DB_CONNECTION'] &&
@@ -141,7 +152,6 @@ module Spree
141
152
  # Callbacks
142
153
  before_validation :set_default_code, on: :create
143
154
  before_save :ensure_default_exists_and_is_unique
144
- after_create :ensure_default_market
145
155
  after_create :create_default_policies
146
156
 
147
157
  #
@@ -165,7 +175,7 @@ module Spree
165
175
  else
166
176
  Spree::Deprecation.warn(
167
177
  'Spree::Store.default returning a new unpersisted store when no default store exists is deprecated ' \
168
- 'and will be removed in Spree 5.5. Please ensure a default store is created before calling Store.default.'
178
+ 'and will be removed in Spree 6.0. Please ensure a default store is created before calling Store.default.'
169
179
  )
170
180
  new(default: true)
171
181
  end
@@ -175,6 +185,10 @@ module Spree
175
185
  Spree::Store.default&.supported_locales_list || []
176
186
  end
177
187
 
188
+ def default_channel
189
+ channels.find_by(code: Spree::Channel::DEFAULT_CODE) || channels.active.first
190
+ end
191
+
178
192
  # @deprecated Use Markets instead. Will be removed in Spree 5.5.
179
193
  def checkout_zone
180
194
  Spree::Deprecation.warn('Store#checkout_zone is deprecated and will be removed in Spree 5.5. Use Markets instead.')
@@ -366,25 +380,6 @@ module Spree
366
380
 
367
381
  private
368
382
 
369
- def ensure_default_market
370
- return if markets.exists?
371
-
372
- country = @default_country_for_market
373
- return if country.blank?
374
-
375
- iso_country = ISO3166::Country[country.iso]
376
-
377
- Spree::Events.disable do
378
- markets.create!(
379
- name: country.name,
380
- currency: iso_country&.currency_code || read_attribute(:default_currency) || 'USD',
381
- default_locale: iso_country&.languages_official&.first || read_attribute(:default_locale) || 'en',
382
- default: true,
383
- countries: [country]
384
- )
385
- end
386
- end
387
-
388
383
  def create_default_policies
389
384
  Spree::Events.disable do
390
385
  [
@@ -401,55 +396,6 @@ module Spree
401
396
  end
402
397
  end
403
398
 
404
- def ensure_default_taxonomies_are_created
405
- Spree::Deprecation.warn('Store#ensure_default_taxonomies_are_created is deprecated and will be removed in Spree 5.5. Please remove it from your codebase')
406
-
407
- Spree::Events.disable do
408
- [
409
- translate_with_store_locale_fallback('spree.taxonomy_categories_name'),
410
- translate_with_store_locale_fallback('spree.taxonomy_brands_name'),
411
- translate_with_store_locale_fallback('spree.taxonomy_collections_name')
412
- ].each do |taxonomy_name|
413
- # Manual exists?/create to work around Mobility bug with find_or_create_by
414
- next if taxonomies.with_matching_name(taxonomy_name).exists?
415
-
416
- taxonomies.create(name: taxonomy_name)
417
- end
418
- end
419
- end
420
-
421
- def ensure_default_automatic_taxons
422
- Spree::Deprecation.warn('Store#ensure_default_automatic_taxons is deprecated and will be removed in Spree 5.5. Please remove it from your codebase')
423
-
424
- Spree::Events.disable do
425
- # Use Mobility-safe lookup for taxonomy
426
- collections_taxonomy = taxonomies.with_matching_name(translate_with_store_locale_fallback('spree.taxonomy_collections_name')).first
427
- return unless collections_taxonomy.present?
428
-
429
- automatic_taxons_config = [
430
- { name: translate_with_store_locale_fallback('spree.automatic_taxon_names.on_sale'),
431
- rule_type: 'Spree::TaxonRules::Sale', rule_value: 'true' },
432
- { name: translate_with_store_locale_fallback('spree.automatic_taxon_names.new_arrivals'), rule_type: 'Spree::TaxonRules::AvailableOn', rule_value: 30 }
433
- ]
434
-
435
- automatic_taxons_config.map do |config|
436
- # Manual exists?/create to work around Mobility bug with first_or_create
437
- taxon_scope = collections_taxonomy.taxons.automatic.with_matching_name(config[:name])
438
-
439
- if taxon_scope.exists?
440
- taxon_scope.first
441
- else
442
- collections_taxonomy.taxons.create!(
443
- name: config[:name],
444
- automatic: true,
445
- parent: collections_taxonomy.root,
446
- taxon_rules: [TaxonRule.new(type: config[:rule_type], value: config[:rule_value])]
447
- )
448
- end
449
- end
450
- end
451
- end
452
-
453
399
  # Translates a key using the store's default locale with fallback to :en
454
400
  def translate_with_store_locale_fallback(key)
455
401
  locale = default_locale.presence&.to_sym || :en