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
@@ -10,7 +10,7 @@ module Spree
10
10
  #
11
11
  # Associations
12
12
  #
13
- belongs_to :store, class_name: 'Spree::Store', touch: true
13
+ belongs_to :store, class_name: 'Spree::Store', touch: true, inverse_of: :markets
14
14
  has_many :market_countries, class_name: 'Spree::MarketCountry', dependent: :destroy
15
15
  has_many :countries, through: :market_countries, class_name: 'Spree::Country'
16
16
  has_many :orders, class_name: 'Spree::Order', dependent: :nullify
@@ -28,6 +28,7 @@ module Spree
28
28
  # Callbacks
29
29
  #
30
30
  before_save :ensure_single_default
31
+ before_destroy :ensure_can_be_deleted
31
32
 
32
33
  #
33
34
  # Scopes
@@ -80,12 +81,67 @@ module Spree
80
81
  @supported_locales_list ||= (supported_locales.to_s.split(',').map(&:strip) << default_locale).compact.uniq.sort
81
82
  end
82
83
 
84
+ # Accepts an Array of locale codes and persists them as a comma-separated
85
+ # string on the `supported_locales` column. Strings are still accepted
86
+ # verbatim so legacy callers (the Rails admin form, raw seed scripts)
87
+ # keep working.
88
+ #
89
+ # @param value [Array<String>, String, nil]
90
+ def supported_locales=(value)
91
+ @supported_locales_list = nil
92
+ normalized = value.is_a?(Array) ? value.compact.uniq.reject(&:blank?).join(',') : value
93
+ super(normalized)
94
+ end
95
+
96
+ # Read companion for `country_isos=`. Returns the sorted list of ISO codes
97
+ # currently assigned to the market.
98
+ #
99
+ # @return [Array<String>]
100
+ def country_isos
101
+ countries.map(&:iso).compact.sort
102
+ end
103
+
104
+ # Accepts an Array of 2-letter ISO codes and resolves them to the matching
105
+ # `Spree::Country` records, replacing the market's countries. Unknown codes
106
+ # are silently dropped — the `validates :countries, presence: true` covers
107
+ # the "every ISO was bogus" case.
108
+ #
109
+ # @param values [Array<String>]
110
+ def country_isos=(values)
111
+ isos = Array(values).compact.map { |v| v.to_s.upcase }.reject(&:blank?)
112
+ self.countries = isos.any? ? Spree::Country.where(iso: isos) : []
113
+ end
114
+
115
+ # Returns true when the market is safe to delete. A market cannot be deleted
116
+ # if it is the default market or the only market in the store, since
117
+ # Spree::Current.currency would have no fallback.
118
+ #
119
+ # @return [Boolean]
120
+ def can_be_deleted?
121
+ !default? && !last_in_store?
122
+ end
123
+
83
124
  private
84
125
 
126
+ def last_in_store?
127
+ !self.class.where(store_id: store_id).where.not(id: id).exists?
128
+ end
129
+
85
130
  def ensure_single_default
86
131
  return unless default? && default_changed?
87
132
 
88
133
  self.class.where(store_id: store_id, default: true).where.not(id: id).update_all(default: false)
89
134
  end
135
+
136
+ def ensure_can_be_deleted
137
+ return if can_be_deleted?
138
+
139
+ if default?
140
+ errors.add(:base, :cannot_destroy_default_market)
141
+ else
142
+ errors.add(:base, :cannot_destroy_last_market)
143
+ end
144
+ throw(:abort)
145
+ end
90
146
  end
91
147
  end
@@ -1,5 +1,26 @@
1
1
  module Spree
2
2
  class Metafield < Spree.base_class
3
+ # Map of API-facing tokens to Ruby STI class names. The wire format is the
4
+ # token (`short_text`); the database column stores the class name. Reads
5
+ # translate to the token via `field_type`; writes accept either form.
6
+ # Plugin-defined types fall through to the raw class name until 6.0 when a
7
+ # registration API lands.
8
+ TYPE_TOKENS = {
9
+ 'short_text' => 'Spree::Metafields::ShortText',
10
+ 'long_text' => 'Spree::Metafields::LongText',
11
+ 'rich_text' => 'Spree::Metafields::RichText',
12
+ 'number' => 'Spree::Metafields::Number',
13
+ 'boolean' => 'Spree::Metafields::Boolean',
14
+ 'json' => 'Spree::Metafields::Json'
15
+ }.freeze
16
+ TYPE_CLASS_TO_TOKEN = TYPE_TOKENS.invert.freeze
17
+
18
+ # Array form consumed by serializers via
19
+ # `typelize field_type: Spree::Metafield::FIELD_TYPE_TOKENS`. Typelizer
20
+ # emits a string-literal union in TypeScript and `{type: string, enum: […]}`
21
+ # in OpenAPI (string-array form was added in typelizer 0.10.0).
22
+ FIELD_TYPE_TOKENS = TYPE_TOKENS.keys.freeze
23
+
3
24
  has_prefix_id :cf
4
25
 
5
26
  #
@@ -8,6 +29,21 @@ module Spree
8
29
  belongs_to :resource, polymorphic: true, touch: true
9
30
  belongs_to :metafield_definition, class_name: 'Spree::MetafieldDefinition'
10
31
 
32
+ #
33
+ # API naming bridge — internal column rename lands in 6.0
34
+ #
35
+ alias_attribute :custom_field_definition_id, :metafield_definition_id
36
+
37
+ # API-facing form of the STI `type` column. Returns the token
38
+ # (`short_text`) when the row's type is a registered built-in; falls
39
+ # through to the raw class name for plugin types.
40
+ #
41
+ # `self[:type]` reads the raw column to bypass AR's STI reader (which
42
+ # returns the resolved class constant, not a string).
43
+ def field_type
44
+ TYPE_CLASS_TO_TOKEN[self[:type]] || self[:type]
45
+ end
46
+
11
47
  #
12
48
  # Delegations
13
49
  #
@@ -43,6 +79,8 @@ module Spree
43
79
  private
44
80
 
45
81
  def set_type_from_metafield_definition
82
+ return if metafield_definition.blank?
83
+
46
84
  self.type ||= metafield_definition.metafield_type
47
85
  end
48
86
 
@@ -18,6 +18,7 @@ module Spree
18
18
  validates :metafield_type, presence: true, inclusion: { in: :valid_available_types }
19
19
  validates :resource_type, presence: true, inclusion: { in: :valid_available_resources }
20
20
  validates :key, uniqueness: { scope: spree_base_uniqueness_scope + [:resource_type, :namespace] }
21
+ validate :field_type_input_must_be_recognized
21
22
 
22
23
  #
23
24
  # Scopes
@@ -51,14 +52,29 @@ module Spree
51
52
  self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on]
52
53
  self.whitelisted_ransackable_scopes = %w[search multi_search]
53
54
 
54
- # 5.5 API naming bridge (DB column rename in 6.0)
55
- # Aligns with OptionType/OptionValue which also expose `label` for the display name.
56
- def label
57
- name
55
+ # API naming bridge internal columns rename in 6.0. `label` matches
56
+ # OptionType/OptionValue conventions. (`storefront_visible` lives on
57
+ # the `Spree::DisplayOn` concern, shared with PaymentMethod + ShippingMethod —
58
+ # see docs/plans/5.5-6.0-display-on-to-boolean.md.)
59
+ alias_attribute :label, :name
60
+
61
+ # API-facing token for the STI subclass name stored in `metafield_type`.
62
+ # Reader returns the registered token (`short_text`); writer accepts either
63
+ # the token or the legacy class-name form for back-compat.
64
+ def field_type
65
+ Spree::Metafield::TYPE_CLASS_TO_TOKEN[metafield_type] || metafield_type
58
66
  end
59
67
 
60
- def label=(value)
61
- self.name = value
68
+ def field_type=(value)
69
+ v = value.to_s
70
+ mapped = Spree::Metafield::TYPE_TOKENS[v]
71
+ # An input is "recognized" when it's either a known token (mapped to a
72
+ # class) or already a known class name. Anything else gets surfaced as
73
+ # an error on `field_type` so API clients get a token-vocabulary
74
+ # message instead of the raw class-name inclusion error on
75
+ # `metafield_type`.
76
+ @field_type_input_recognized = !mapped.nil? || Spree::Metafield::TYPE_CLASS_TO_TOKEN.key?(v)
77
+ self.metafield_type = mapped || value
62
78
  end
63
79
 
64
80
  # Returns the full key with namespace
@@ -91,6 +107,13 @@ module Spree
91
107
  self.class.available_types.map(&:to_s)
92
108
  end
93
109
 
110
+ def field_type_input_must_be_recognized
111
+ return if @field_type_input_recognized.nil? || @field_type_input_recognized
112
+
113
+ tokens = Spree::Metafield::TYPE_TOKENS.keys.join(', ')
114
+ errors.add(:field_type, "is not a known custom field type (expected one of: #{tokens})")
115
+ end
116
+
94
117
  def valid_available_resources
95
118
  self.class.available_resources.map(&:to_s)
96
119
  end
@@ -3,6 +3,16 @@ module Spree
3
3
  class Json < Spree::Metafield
4
4
  validate :value_must_be_valid_json
5
5
 
6
+ # Accept either a JSON-serialized String (from CSV / Admin UI text
7
+ # input) or a raw Hash / Array (from API callers that ship parsed
8
+ # objects). Non-String inputs get JSON-serialized so the underlying
9
+ # text column always holds canonical JSON.
10
+ #
11
+ # @param raw [String, Hash, Array, nil]
12
+ def value=(raw)
13
+ super(raw.is_a?(Hash) || raw.is_a?(Array) ? raw.to_json : raw)
14
+ end
15
+
6
16
  def serialize_value
7
17
  JSON.parse(value)
8
18
  rescue JSON::ParserError
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :sub
4
4
 
5
5
  include Spree::Metafields
6
+ include Spree::SingleStoreResource
6
7
 
7
8
  publishes_lifecycle_events
8
9
 
@@ -12,6 +13,7 @@ module Spree
12
13
  # Associations
13
14
  #
14
15
  belongs_to :user, optional: true, class_name: Spree.user_class&.name
16
+ belongs_to :store, class_name: 'Spree::Store', required: true
15
17
 
16
18
  #
17
19
  # Validations
@@ -19,7 +21,7 @@ module Spree
19
21
  validates :email,
20
22
  presence: true,
21
23
  format: { with: URI::MailTo::EMAIL_REGEXP },
22
- uniqueness: { case_sensitive: false, scope: spree_base_uniqueness_scope }
24
+ uniqueness: { case_sensitive: false, scope: spree_base_uniqueness_scope + [:store_id] }
23
25
 
24
26
  #
25
27
  # Scopes
@@ -30,6 +32,7 @@ module Spree
30
32
  #
31
33
  # Callbacks
32
34
  #
35
+ before_validation :set_store, unless: :store_id?
33
36
  normalizes :email, with: ->(email) { email.to_s.strip.downcase.presence }
34
37
 
35
38
  #
@@ -52,8 +55,15 @@ module Spree
52
55
  Spree::CSV::NewsletterSubscriberPresenter.new(self).call
53
56
  end
54
57
 
55
- def self.subscribe(email:, user: nil)
56
- Spree::Newsletter::Subscribe.new(email: email, current_user: user).call
58
+ def self.subscribe(email:, user: nil, store: nil, redirect_url: nil)
59
+ store ||= Spree::Current.store
60
+
61
+ Spree::Newsletter::Subscribe.new(
62
+ email: email,
63
+ current_user: user,
64
+ current_store: store,
65
+ redirect_url: redirect_url
66
+ ).call
57
67
  end
58
68
 
59
69
  def self.verify(token:)
@@ -61,5 +71,11 @@ module Spree
61
71
 
62
72
  Spree::Newsletter::Verify.new(subscriber: subscriber).call
63
73
  end
74
+
75
+ private
76
+
77
+ def set_store
78
+ self.store ||= Spree::Current.store
79
+ end
64
80
  end
65
81
  end
@@ -27,7 +27,14 @@ module Spree
27
27
  #
28
28
  # Associations
29
29
  with_options dependent: :destroy, inverse_of: :option_type do
30
- has_many :option_values, -> { order(:position) }
30
+ # `autosave: true` makes the parent's `save`/`update`:
31
+ # - persist any built / mutated children in one transaction,
32
+ # - collect their validation errors onto `self.errors`,
33
+ # - destroy any child marked via `mark_for_destruction`.
34
+ # The custom `option_values=` writer below leans on this so the v3
35
+ # ResourceController gets `save returning false` + structured errors
36
+ # rather than raised exceptions.
37
+ has_many :option_values, -> { order(:position) }, autosave: true
31
38
  has_many :product_option_types
32
39
  end
33
40
  has_many :products, through: :product_option_types
@@ -73,12 +80,6 @@ module Spree
73
80
  after_update :touch_all_products, if: -> { saved_changes.key?(:presentation) }
74
81
  after_destroy :touch_all_products
75
82
 
76
- # legacy, name itself is now parameterized before saving
77
- def filter_param
78
- Spree::Deprecation.warn('Spree::OptionType#filter_param is deprecated and will be removed in Spree 5.5. Please use Spree::OptionType#name instead.')
79
- name.parameterize
80
- end
81
-
82
83
  def self.color
83
84
  colors.first
84
85
  end
@@ -94,6 +95,46 @@ module Spree
94
95
  color_swatch?
95
96
  end
96
97
 
98
+ # Syncs option values from an array of hashes by mutating the in-memory
99
+ # `option_values` association — built/assigned children get persisted by
100
+ # `autosave: true` when the parent saves, and absent IDs get destroyed
101
+ # via `mark_for_destruction`. The single transaction is owned by the
102
+ # parent's `save`, so validation failures surface as `errors` and the
103
+ # whole thing rolls back together.
104
+ #
105
+ # Falls back to ActiveRecord's collection writer when given OptionValue
106
+ # records (e.g. from `accepts_nested_attributes_for` used by the legacy admin).
107
+ #
108
+ # @param option_values_params [Array<Hash>] array of option value attribute hashes
109
+ # @return [void]
110
+ def option_values=(option_values_params)
111
+ return super if option_values_params.blank? || option_values_params.first.is_a?(Spree::OptionValue)
112
+
113
+ # Load the association into the in-memory collection so subsequent
114
+ # `option_values.build` / `mark_for_destruction` mutations stay on the
115
+ # same instances `autosave` will traverse at parent-save time.
116
+ existing_by_id = option_values.to_a.index_by(&:id)
117
+ retained_ids = []
118
+
119
+ option_values_params.each do |value_data|
120
+ data = value_data.to_h.with_indifferent_access
121
+ value_id = data.delete(:id)
122
+
123
+ record = if value_id.present?
124
+ existing_by_id[Spree::PrefixedId.decode_prefixed_id(value_id) || value_id] ||
125
+ raise(ActiveRecord::RecordNotFound.new("Couldn't find Spree::OptionValue with param=#{value_id}", 'Spree::OptionValue'))
126
+ else
127
+ option_values.build
128
+ end
129
+ record.assign_attributes(data)
130
+ retained_ids << record.id if record.persisted?
131
+ end
132
+
133
+ existing_by_id.each_value do |existing|
134
+ existing.mark_for_destruction unless retained_ids.include?(existing.id)
135
+ end
136
+ end
137
+
97
138
  private
98
139
 
99
140
  def touch_all_products
@@ -135,7 +135,7 @@ module Spree
135
135
  def subscribe_to_newsletter
136
136
  return unless accept_marketing?
137
137
 
138
- Spree::NewsletterSubscriber.subscribe(email: email, user: user)
138
+ Spree::NewsletterSubscriber.subscribe(email: email, user: user, store: store)
139
139
  end
140
140
 
141
141
  def self.go_to_state(name, options = {})
@@ -317,8 +317,8 @@ module Spree
317
317
  # attributes for a single payment and its source, discarding attributes
318
318
  # for payment methods other than the one selected
319
319
  #
320
- # In case a existing credit card is provided it needs to build the payment
321
- # attributes from scratch so we can set the amount. example payload:
320
+ # If an existing credit card is provided, build the payment attributes
321
+ # from scratch so the amount can be set. Example payload:
322
322
  #
323
323
  # {
324
324
  # "order": {
@@ -9,6 +9,11 @@ module Spree
9
9
  class Order < Spree.base_class
10
10
  has_prefix_id :or # Stripe: or_
11
11
 
12
+ # Legacy free-text `channel` column was replaced by the `channel_id` FK
13
+ # (see 6.0-order-routing.md). The string column stays in the DB so the
14
+ # 5.4-to-5.5 backfill rake can read it; AR ignores it everywhere else.
15
+ self.ignored_columns += ['channel']
16
+
12
17
  PAYMENT_STATES = %w(balance_due credit_owed failed paid void)
13
18
  SHIPMENT_STATES = %w(backorder canceled partial pending ready shipped)
14
19
  LINE_ITEM_REMOVABLE_STATES = %w(cart address delivery payment confirm resumed)
@@ -104,23 +109,42 @@ module Spree
104
109
  go_to_state :complete
105
110
  end
106
111
 
107
- self.whitelisted_ransackable_associations = %w[shipments user created_by approver canceler promotions bill_address ship_address line_items store]
112
+ self.whitelisted_ransackable_associations = %w[shipments user created_by approver canceler promotions bill_address ship_address line_items store channel tags]
108
113
  self.whitelisted_ransackable_attributes = %w[
109
- completed_at email number state payment_state shipment_state
110
- total item_total item_count considered_risky channel
114
+ completed_at email number state status payment_state shipment_state
115
+ total item_total item_count considered_risky channel_id currency
111
116
  ]
112
- self.whitelisted_ransackable_scopes = %w[refunded partially_refunded search multi_search]
117
+ self.whitelisted_ransackable_scopes = %w[complete incomplete refunded partially_refunded search multi_search]
113
118
 
114
119
  attr_reader :coupon_code
115
120
  attr_accessor :temporary_address
116
121
 
122
+ # Set to false on admin-initiated flows to suppress customer-facing emails.
123
+ attr_accessor :notify_customer
124
+
117
125
  attribute :state_machine_resumed, :boolean
118
126
 
127
+ STATUSES = %w[draft placed canceled].freeze
128
+
129
+ attribute :status, :string, default: 'draft'
130
+ validates :status, inclusion: { in: STATUSES }
131
+
132
+ scope :drafts, -> { where(status: 'draft') }
133
+ scope :placed_orders, -> { where(status: 'placed') }
134
+ scope :canceled_orders, -> { where(status: 'canceled') }
135
+
119
136
  acts_as_taggable_on :tags
120
137
  acts_as_taggable_tenant :store_id
121
138
 
139
+ def tags=(tags)
140
+ self.tag_list = tags
141
+ end
142
+
122
143
  ASSOCIATED_USER_ATTRIBUTES = [:user_id, :email, :bill_address_id, :ship_address_id]
123
144
 
145
+ # 6.0 forward-compat: User→Customer rename. Column stays user_id in 5.x.
146
+ alias_attribute :customer_id, :user_id
147
+
124
148
  belongs_to :user, class_name: "::#{Spree.user_class}", optional: true, autosave: true
125
149
  belongs_to :created_by, class_name: "::#{Spree.admin_user_class}", optional: true
126
150
  belongs_to :approver, class_name: "::#{Spree.admin_user_class}", optional: true
@@ -140,6 +164,8 @@ module Spree
140
164
 
141
165
  belongs_to :store, class_name: 'Spree::Store'
142
166
  belongs_to :market, class_name: 'Spree::Market', optional: true
167
+ belongs_to :channel, class_name: 'Spree::Channel', optional: true
168
+ belongs_to :preferred_stock_location, class_name: 'Spree::StockLocation', optional: true
143
169
 
144
170
  with_options dependent: :destroy do
145
171
  has_many :state_changes, as: :stateful, class_name: 'Spree::StateChange'
@@ -148,11 +174,14 @@ module Spree
148
174
  has_many :payment_sessions, inverse_of: :order, class_name: 'Spree::PaymentSession'
149
175
  has_many :return_authorizations, inverse_of: :order, class_name: 'Spree::ReturnAuthorization'
150
176
  has_many :adjustments, -> { order(:created_at) }, as: :adjustable, class_name: 'Spree::Adjustment'
177
+ has_many :cancellations, -> { order(:created_at) }, inverse_of: :order, class_name: 'Spree::OrderCancellation'
178
+ has_many :approvals, -> { order(:created_at) }, inverse_of: :order, class_name: 'Spree::OrderApproval'
151
179
  end
152
180
  has_many :reimbursements, inverse_of: :order, class_name: 'Spree::Reimbursement'
153
181
  has_many :customer_returns, class_name: 'Spree::CustomerReturn', through: :return_authorizations
154
182
  has_many :line_item_adjustments, through: :line_items, source: :adjustments
155
183
  has_many :inventory_units, inverse_of: :order, class_name: 'Spree::InventoryUnit'
184
+ has_many :stock_reservations, class_name: 'Spree::StockReservation', inverse_of: :order, dependent: :destroy
156
185
  has_many :return_items, through: :inventory_units, class_name: 'Spree::ReturnItem'
157
186
  has_many :variants, through: :line_items
158
187
  has_many :products, through: :variants
@@ -194,6 +223,7 @@ module Spree
194
223
  # Needs to happen before save_permalink is called
195
224
  before_validation :ensure_store_presence
196
225
  before_validation :ensure_market_presence
226
+ before_validation :ensure_channel_presence
197
227
  before_validation :ensure_currency_presence
198
228
  before_validation :ensure_locale_presence
199
229
  before_validation :resolve_market_from_currency, if: -> { persisted? && currency_changed? && !skip_market_resolution }
@@ -348,6 +378,21 @@ module Spree
348
378
  completed_at.present?
349
379
  end
350
380
 
381
+ # True when the order is mid-checkout: past the `cart` state but not yet
382
+ # completed or canceled. Used by stock reservation hooks and any flow
383
+ # that should only run during the active checkout phase.
384
+ def in_checkout?
385
+ !cart? && !complete? && !canceled?
386
+ end
387
+
388
+ def draft?
389
+ status == 'draft'
390
+ end
391
+
392
+ def placed?
393
+ status == 'placed'
394
+ end
395
+
351
396
  # Checks if the order is fully refunded
352
397
  # @return [Boolean]
353
398
  def order_refunded?
@@ -459,6 +504,12 @@ module Spree
459
504
  self.market ||= Spree::Current.market || store&.default_market
460
505
  end
461
506
 
507
+ def ensure_channel_presence
508
+ return if channel_id.present?
509
+
510
+ self.channel = store&.default_channel
511
+ end
512
+
462
513
  def allow_cancel?
463
514
  return false if !completed? || canceled?
464
515
 
@@ -591,6 +642,7 @@ module Spree
591
642
  end
592
643
 
593
644
  updater.update_shipment_state
645
+ self.status = 'placed'
594
646
  save!
595
647
  updater.run_hooks
596
648
 
@@ -719,7 +771,34 @@ module Spree
719
771
  # and are not returned or shipped should be deleted
720
772
  inventory_units.on_hand_or_backordered.delete_all
721
773
 
722
- self.shipments = Spree::Stock::Coordinator.new(self).shipments
774
+ self.shipments = order_routing_strategy.for_allocation.map do |package|
775
+ package.to_shipment.tap { |s| s.address_id = ship_address_id }
776
+ end
777
+ end
778
+
779
+ # Resolves the routing strategy from the channel override first, then the
780
+ # store default. Only a registered Spree::OrderRouting::Strategy::Base
781
+ # subclass is used; any other value (an unregistered/typo'd class, or a
782
+ # strategy that was unregistered after being persisted) is logged and
783
+ # skipped rather than raised, falling back to the default Rules strategy so
784
+ # a misconfiguration can't take down cart display or checkout.
785
+ #
786
+ # @return [Spree::OrderRouting::Strategy::Base]
787
+ def order_routing_strategy
788
+ klass = valid_order_routing_strategy_class(channel&.preferred_order_routing_strategy) ||
789
+ valid_order_routing_strategy_class(store.preferred_order_routing_strategy) ||
790
+ Spree::OrderRouting::Strategy::Rules
791
+
792
+ klass.new(order: self)
793
+ end
794
+
795
+ # Cascade for the `preferred_location` rule kind. Channel and B2B sources
796
+ # are layered in by their respective plans.
797
+ #
798
+ # @return [Integer, nil]
799
+ def inferred_preferred_stock_location_id
800
+ preferred_stock_location_id.presence ||
801
+ created_by&.try(:preferred_stock_location_id)
723
802
  end
724
803
 
725
804
  # Returns the total weight of the inventory units in the order
@@ -965,6 +1044,19 @@ module Spree
965
1044
 
966
1045
  private
967
1046
 
1047
+ def valid_order_routing_strategy_class(klass_name)
1048
+ return if klass_name.blank?
1049
+
1050
+ klass = Spree.order_routing.strategies.find { |strategy| strategy.to_s == klass_name.to_s }
1051
+ return klass if klass
1052
+
1053
+ Rails.logger.warn(
1054
+ "[Spree] Ignoring unregistered order routing strategy #{klass_name.inspect} " \
1055
+ "for order #{number.inspect}; falling back to the default strategy."
1056
+ )
1057
+ nil
1058
+ end
1059
+
968
1060
  def link_by_email
969
1061
  self.email = user.email if user
970
1062
  end
@@ -998,6 +1090,8 @@ module Spree
998
1090
  end
999
1091
 
1000
1092
  def after_cancel
1093
+ update_column(:status, 'canceled')
1094
+
1001
1095
  shipments.each(&:cancel!)
1002
1096
 
1003
1097
  # payments fully covered by gift card won't be refunded
@@ -1015,6 +1109,8 @@ module Spree
1015
1109
  end
1016
1110
 
1017
1111
  def after_resume
1112
+ update_column(:status, 'placed')
1113
+
1018
1114
  shipments.each(&:resume!)
1019
1115
  consider_risk
1020
1116
  send_order_resumed_webhook
@@ -1089,7 +1185,7 @@ module Spree
1089
1185
  end
1090
1186
 
1091
1187
  def publish_order_completed_event
1092
- publish_event('order.completed')
1188
+ publish_event('order.completed', event_payload.merge(notify_customer: notify_customer))
1093
1189
  end
1094
1190
 
1095
1191
  def publish_order_resumed_event
@@ -0,0 +1,19 @@
1
+ module Spree
2
+ class OrderApproval < Spree.base_class
3
+ has_prefix_id :appr
4
+
5
+ STATUSES = %w[pending approved rejected].freeze
6
+
7
+ attribute :metadata, default: -> { {} }
8
+
9
+ belongs_to :order, class_name: 'Spree::Order', inverse_of: :approvals
10
+ belongs_to :approver, polymorphic: true, optional: true
11
+
12
+ validates :order, presence: true
13
+ validates :status, presence: true, inclusion: { in: STATUSES }
14
+
15
+ scope :approved, -> { where(status: 'approved') }
16
+ scope :pending, -> { where(status: 'pending') }
17
+ scope :rejected, -> { where(status: 'rejected') }
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module Spree
2
+ class OrderCancellation < Spree.base_class
3
+ has_prefix_id :cncl
4
+
5
+ REASONS = %w[customer declined fraud inventory staff other expired].freeze
6
+
7
+ attribute :restock_items, :boolean, default: false
8
+ attribute :refund_payments, :boolean, default: false
9
+ attribute :notify_customer, :boolean, default: false
10
+ attribute :metadata, default: -> { {} }
11
+
12
+ belongs_to :order, class_name: 'Spree::Order', inverse_of: :cancellations
13
+ belongs_to :canceled_by, polymorphic: true, optional: true
14
+
15
+ validates :order, presence: true
16
+ validates :reason, presence: true, inclusion: { in: REASONS }
17
+ validates :refund_amount, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
18
+ end
19
+ end
@@ -17,13 +17,23 @@ module Spree
17
17
  # In case shipment is passed the stock location should only unstock or
18
18
  # restock items if the order is completed. That is so because stock items
19
19
  # are always unstocked when the order is completed through +shipment.finalize+
20
- def verify(shipment = nil, is_updated: false)
20
+ def verify(shipment = nil, is_updated: false, removing: false)
21
21
  return unless order.completed? || shipment.present?
22
22
 
23
23
  units_count = inventory_units.reload.sum(&:quantity)
24
24
  line_item_changed = is_updated ? !line_item.saved_changes? : !line_item.changed?
25
25
 
26
- if units_count < line_item.quantity
26
+ if removing
27
+ # When the line item is being destroyed, only remove existing inventory.
28
+ # Adding here would create units that the LineItem `dependent: :destroy`
29
+ # cascade can't see (set_up_inventory writes through shipment.inventory_units,
30
+ # leaving line_item.inventory_units stale), producing an orphaned unit.
31
+ #
32
+ # Bypass `remove` because it routes through `set_quantity_to_remove` which
33
+ # assumes a quantity-change scenario; here we want to drain everything tied
34
+ # to this line item regardless of `line_item.quantity`.
35
+ remove_all_units(units_count, shipment) if units_count.positive?
36
+ elsif units_count < line_item.quantity
27
37
  quantity = line_item.quantity - units_count
28
38
 
29
39
  shipment ||= determine_target_shipment
@@ -49,6 +59,18 @@ module Spree
49
59
  end
50
60
  end
51
61
 
62
+ def remove_all_units(quantity, target_shipment = nil)
63
+ if target_shipment.present?
64
+ remove_from_shipment(target_shipment, quantity)
65
+ else
66
+ order.shipments.each do |shipment|
67
+ break if quantity.zero?
68
+
69
+ quantity -= remove_from_shipment(shipment, quantity)
70
+ end
71
+ end
72
+ end
73
+
52
74
  def set_quantity_to_remove(units_count)
53
75
  if (units_count - line_item.quantity).zero?
54
76
  line_item.quantity