spree_core 5.4.3 → 5.5.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +0 -82
  3. data/app/helpers/spree/currency_helper.rb +0 -12
  4. data/app/helpers/spree/products_helper.rb +0 -8
  5. data/app/jobs/spree/base_job.rb +18 -0
  6. data/app/jobs/spree/events/subscriber_job.rb +2 -1
  7. data/app/jobs/spree/exports/generate_job.rb +11 -0
  8. data/app/jobs/spree/images/save_from_url_job.rb +23 -8
  9. data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
  10. data/app/jobs/spree/imports/base_job.rb +15 -0
  11. data/app/jobs/spree/imports/create_categories_job.rb +37 -0
  12. data/app/jobs/spree/imports/create_rows_job.rb +1 -3
  13. data/app/jobs/spree/imports/process_group_job.rb +8 -6
  14. data/app/jobs/spree/imports/process_rows_job.rb +1 -3
  15. data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
  16. data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
  17. data/app/jobs/spree/reports/generate_job.rb +11 -0
  18. data/app/jobs/spree/search_provider/index_job.rb +5 -1
  19. data/app/jobs/spree/search_provider/remove_job.rb +4 -0
  20. data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
  21. data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
  22. data/app/models/concerns/spree/display_on.rb +31 -0
  23. data/app/models/concerns/spree/metafields.rb +167 -5
  24. data/app/models/concerns/spree/preference_schema.rb +191 -0
  25. data/app/models/concerns/spree/prefixed_id.rb +94 -11
  26. data/app/models/concerns/spree/product_scopes.rb +36 -17
  27. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  28. data/app/models/concerns/spree/search_indexable.rb +8 -7
  29. data/app/models/concerns/spree/searchable.rb +11 -2
  30. data/app/models/concerns/spree/stores/channels.rb +20 -0
  31. data/app/models/concerns/spree/stores/markets.rb +21 -5
  32. data/app/models/concerns/spree/typed_associations.rb +120 -0
  33. data/app/models/concerns/spree/user_methods.rb +71 -12
  34. data/app/models/spree/ability.rb +4 -117
  35. data/app/models/spree/api_key.rb +53 -0
  36. data/app/models/spree/asset.rb +28 -5
  37. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  38. data/app/models/spree/base.rb +18 -1
  39. data/app/models/spree/channel.rb +159 -0
  40. data/app/models/spree/country.rb +2 -0
  41. data/app/models/spree/current.rb +5 -1
  42. data/app/models/spree/custom_field.rb +9 -0
  43. data/app/models/spree/custom_field_definition.rb +7 -0
  44. data/app/models/spree/customer_group.rb +8 -2
  45. data/app/models/spree/export.rb +30 -3
  46. data/app/models/spree/gateway.rb +25 -0
  47. data/app/models/spree/gift_card.rb +1 -1
  48. data/app/models/spree/gift_card_batch.rb +4 -1
  49. data/app/models/spree/import.rb +5 -0
  50. data/app/models/spree/import_row.rb +12 -0
  51. data/app/models/spree/line_item.rb +6 -1
  52. data/app/models/spree/market.rb +32 -1
  53. data/app/models/spree/metafield.rb +38 -0
  54. data/app/models/spree/metafield_definition.rb +29 -6
  55. data/app/models/spree/metafields/json.rb +10 -0
  56. data/app/models/spree/newsletter_subscriber.rb +19 -3
  57. data/app/models/spree/option_type.rb +48 -7
  58. data/app/models/spree/order/checkout.rb +3 -3
  59. data/app/models/spree/order.rb +102 -6
  60. data/app/models/spree/order_approval.rb +19 -0
  61. data/app/models/spree/order_cancellation.rb +19 -0
  62. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  63. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  64. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  65. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  66. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  67. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  68. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  69. data/app/models/spree/order_routing/strategy/rules.rb +81 -0
  70. data/app/models/spree/order_routing_rule.rb +75 -0
  71. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  72. data/app/models/spree/permission_sets/product_display.rb +2 -0
  73. data/app/models/spree/permission_sets/product_management.rb +2 -0
  74. data/app/models/spree/price.rb +14 -1
  75. data/app/models/spree/price_list.rb +129 -17
  76. data/app/models/spree/price_rule.rb +11 -1
  77. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  78. data/app/models/spree/price_rules/market_rule.rb +16 -1
  79. data/app/models/spree/price_rules/user_rule.rb +21 -2
  80. data/app/models/spree/product/channels.rb +149 -0
  81. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  82. data/app/models/spree/product/slugs.rb +1 -1
  83. data/app/models/spree/product.rb +172 -31
  84. data/app/models/spree/product_publication.rb +43 -0
  85. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  86. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  87. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  88. data/app/models/spree/promotion/rules/country.rb +40 -18
  89. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  90. data/app/models/spree/promotion/rules/product.rb +4 -0
  91. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  92. data/app/models/spree/promotion/rules/user.rb +21 -0
  93. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  94. data/app/models/spree/promotion.rb +22 -1
  95. data/app/models/spree/promotion_action.rb +17 -11
  96. data/app/models/spree/promotion_rule.rb +17 -18
  97. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  98. data/app/models/spree/stock/availability_validator.rb +1 -1
  99. data/app/models/spree/stock/quantifier.rb +89 -9
  100. data/app/models/spree/stock_item.rb +36 -0
  101. data/app/models/spree/stock_location.rb +52 -0
  102. data/app/models/spree/stock_reservation.rb +38 -0
  103. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  104. data/app/models/spree/store.rb +18 -72
  105. data/app/models/spree/store_credit.rb +0 -8
  106. data/app/models/spree/store_product.rb +11 -23
  107. data/app/models/spree/taxon.rb +0 -5
  108. data/app/models/spree/user_identity.rb +1 -2
  109. data/app/models/spree/variant.rb +132 -18
  110. data/app/models/spree/variant_media.rb +46 -0
  111. data/app/models/spree/webhook_delivery.rb +1 -1
  112. data/app/models/spree/webhook_endpoint.rb +24 -0
  113. data/app/models/spree/wished_item.rb +0 -13
  114. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  115. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  116. data/app/presenters/spree/variant_presenter.rb +4 -3
  117. data/app/services/spree/addresses/update.rb +6 -8
  118. data/app/services/spree/cart/add_item.rb +10 -0
  119. data/app/services/spree/cart/empty.rb +2 -0
  120. data/app/services/spree/cart/remove_line_item.rb +10 -0
  121. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  122. data/app/services/spree/cart/set_quantity.rb +10 -0
  123. data/app/services/spree/carts/complete.rb +1 -0
  124. data/app/services/spree/carts/create.rb +1 -0
  125. data/app/services/spree/carts/update.rb +18 -2
  126. data/app/services/spree/carts/upsert_items.rb +6 -6
  127. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  128. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  129. data/app/services/spree/newsletter/link_user.rb +53 -0
  130. data/app/services/spree/newsletter/subscribe.rb +31 -9
  131. data/app/services/spree/orders/approve.rb +27 -6
  132. data/app/services/spree/orders/build_shipments.rb +29 -0
  133. data/app/services/spree/orders/cancel.rb +34 -3
  134. data/app/services/spree/orders/complete.rb +53 -0
  135. data/app/services/spree/orders/create.rb +156 -0
  136. data/app/services/spree/orders/update.rb +51 -0
  137. data/app/services/spree/orders/upsert_items.rb +70 -0
  138. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  139. data/app/services/spree/products/duplicator.rb +1 -1
  140. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  141. data/app/services/spree/sample_data/loader.rb +30 -0
  142. data/app/services/spree/stock_reservations/extend.rb +19 -0
  143. data/app/services/spree/stock_reservations/release.rb +12 -0
  144. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  145. data/app/services/spree/taxons/remove_products.rb +7 -1
  146. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  147. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  148. data/config/locales/en.yml +27 -10
  149. data/config/routes.rb +9 -0
  150. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  151. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  152. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  153. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  154. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  155. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  156. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  157. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  158. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  159. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  160. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  161. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  162. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  163. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  164. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  165. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  166. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  167. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  168. data/db/sample_data/channels.rb +12 -0
  169. data/db/sample_data/orders.rb +1 -1
  170. data/db/sample_data/products.csv +212 -212
  171. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  172. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  173. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  174. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  175. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  176. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  177. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  178. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  179. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
  180. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  181. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  182. data/lib/generators/spree/model/model_generator.rb +73 -7
  183. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  184. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  185. data/lib/spree/core/configuration.rb +7 -0
  186. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  187. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  188. data/lib/spree/core/controller_helpers/order.rb +0 -19
  189. data/lib/spree/core/dependencies.rb +5 -2
  190. data/lib/spree/core/engine.rb +54 -7
  191. data/lib/spree/core/permission_configuration.rb +15 -0
  192. data/lib/spree/core/preferences/masking.rb +47 -0
  193. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  194. data/lib/spree/core/version.rb +1 -1
  195. data/lib/spree/core.rb +56 -5
  196. data/lib/spree/permitted_attributes.rb +9 -7
  197. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  198. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  199. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  200. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  201. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  202. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  203. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  204. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  205. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  206. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  207. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  208. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  209. data/lib/spree/testing_support/store.rb +10 -0
  210. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  211. data/lib/tasks/channels.rake +94 -0
  212. data/lib/tasks/core.rake +1 -0
  213. data/lib/tasks/media.rake +27 -0
  214. data/lib/tasks/products.rake +4 -6
  215. data/lib/tasks/publications.rake +60 -0
  216. data/lib/tasks/upgrade.rake +211 -0
  217. metadata +83 -18
  218. data/app/finders/spree/variants/visible_finder.rb +0 -23
  219. data/app/paginators/spree/shared/paginate.rb +0 -30
  220. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  221. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  222. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  223. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  224. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  225. data/app/services/spree/classifications/reposition.rb +0 -23
  226. data/app/sorters/spree/orders/sort.rb +0 -10
  227. data/lib/spree/core/controller_helpers/common.rb +0 -14
  228. data/lib/spree/core/token_generator.rb +0 -23
  229. data/lib/spree/database_type_utilities.rb +0 -22
  230. data/lib/spree/testing_support/bar_ability.rb +0 -14
  231. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -1,10 +1,12 @@
1
1
  class Spree::Base < ApplicationRecord
2
2
  include Spree::Preferences::Preferable
3
+ include Spree::PreferenceSchema
3
4
  include Spree::RansackableAttributes
4
5
  include Spree::TranslatableResourceScopes
5
6
  include Spree::IntegrationsConcern
6
7
  include Spree::Publishable
7
8
  include Spree::PrefixedId
9
+ include Spree::TypedAssociations
8
10
 
9
11
  after_initialize do
10
12
  if has_attribute?(:preferences) && !preferences.nil?
@@ -57,6 +59,10 @@ class Spree::Base < ApplicationRecord
57
59
  true
58
60
  end
59
61
 
62
+ def mysql_adapter?
63
+ ActiveRecord::Base.connection.adapter_name.downcase.include?('mysql')
64
+ end
65
+
60
66
  def self.json_api_columns
61
67
  column_names.reject { |c| c.match(/_id$|id|preferences|(.*)password|(.*)token|(.*)api_key|^original_(.*)/) }
62
68
  end
@@ -71,10 +77,21 @@ class Spree::Base < ApplicationRecord
71
77
  column_names.reject { |c| skipped_attributes.include?(c.to_s) }
72
78
  end
73
79
 
74
- def self.json_api_type
80
+ # Public-API shorthand for this class, used as the `type` value on the
81
+ # wire (e.g. `"currency"` for `Spree::Promotion::Rules::Currency`,
82
+ # `"flat_rate"` for `Spree::Calculator::FlatRate`). Defaults to the
83
+ # demodulized + underscored leaf; override on a subclass when the wire
84
+ # format should stay stable across class renames.
85
+ def self.api_type
75
86
  to_s.demodulize.underscore
76
87
  end
77
88
 
89
+ # Backwards-compatible alias for `.api_type`. Delegates so subclass
90
+ # overrides of `api_type` are honored.
91
+ def self.json_api_type
92
+ api_type
93
+ end
94
+
78
95
  def self.to_tom_select_json
79
96
  pluck(:name, :id).map do |name, id|
80
97
  {
@@ -0,0 +1,159 @@
1
+ module Spree
2
+ # Lightweight distribution surface within a Store: online storefront, POS,
3
+ # marketplace integration, wholesale portal. Channels carry order
4
+ # attribution and the routing-strategy override.
5
+ class Channel < Spree.base_class
6
+ DEFAULT_CODE = 'online'.freeze
7
+
8
+ has_prefix_id :ch
9
+
10
+ include Spree::SingleStoreResource
11
+ include Spree::Metafields
12
+ include Spree::Metadata
13
+ include Spree::OrderRouting::HasStrategyPreference
14
+
15
+ # Empty -> falls back to the Store-level preference.
16
+ preference :order_routing_strategy, :string, default: nil
17
+
18
+ belongs_to :store, class_name: 'Spree::Store'
19
+
20
+ has_many :orders, class_name: 'Spree::Order', inverse_of: :channel, dependent: :nullify
21
+ has_many :order_routing_rules, class_name: 'Spree::OrderRoutingRule', dependent: :destroy
22
+ has_many :publications, class_name: 'Spree::ProductPublication', dependent: :destroy
23
+ has_many :products, through: :publications, class_name: 'Spree::Product'
24
+
25
+ attribute :active, :boolean, default: true
26
+
27
+ # HTTP headers arrive as ASCII-8BIT; +parameterize+ raises on those.
28
+ normalizes :code, with: ->(value) { value.to_s.dup.force_encoding(Encoding::UTF_8).parameterize.presence }
29
+
30
+ before_validation :backfill_code_from_name, if: -> { code.blank? && name.present? }
31
+ before_validation :promote_first_channel_to_default
32
+
33
+ validates :name, :store, presence: true
34
+ validates :code, presence: true, uniqueness: { scope: spree_base_uniqueness_scope + [:store_id] }
35
+
36
+ # Demote any prior default in the same transaction so the partial unique
37
+ # index ("only one default per store") never sees two TRUE rows. Runs
38
+ # before save so MySQL — which can't enforce a partial unique index — also
39
+ # arrives at a single default without relying on DB constraints.
40
+ before_save :demote_other_defaults, if: -> { default? && will_save_change_to_default? }
41
+ before_destroy :ensure_not_default
42
+ after_create :ensure_default_order_routing_rules
43
+
44
+ scope :active, -> { where(active: true) }
45
+ scope :default, -> { where(default: true) }
46
+
47
+ self.whitelisted_ransackable_attributes = %w[name code active default store_id]
48
+
49
+ # Publishes the given products on this channel by creating/upserting ProductPublications.
50
+ # Optionally sets the publication window; if not given, the products will be published immediately
51
+ # with no end date.
52
+ # @param product_ids [Array<Integer>, Integer] the IDs of the products to publish on this channel
53
+ # @param published_at [Time, nil] when the publications go live; nil means immediately
54
+ # @param unpublished_at [Time, nil] when the publications come down; nil means never
55
+ # @return [Integer] the number of ProductPublications created or updated
56
+ def add_products(product_ids, published_at: nil, unpublished_at: nil)
57
+ product_ids = Array(product_ids).map(&:to_s).uniq
58
+ return 0 if product_ids.empty?
59
+
60
+ now = Time.current
61
+ # Only include window columns in the upsert payload when the caller
62
+ # explicitly passed a value. Leaving them out keeps existing
63
+ # publication schedules intact on re-publish — otherwise +on_duplicate:
64
+ # :update+ + +update_only+ would rewrite scheduled +published_at+ /
65
+ # +unpublished_at+ to NULL whenever the bulk action re-runs without
66
+ # dates.
67
+ base = { channel_id: id, created_at: now, updated_at: now }
68
+ base[:published_at] = published_at unless published_at.nil?
69
+ base[:unpublished_at] = unpublished_at unless unpublished_at.nil?
70
+
71
+ records_to_upsert = product_ids.map { |product_id| base.merge(product_id: product_id) }
72
+
73
+ # Only update the window columns the caller passed. When neither was
74
+ # passed, treat re-publish as a no-op (+on_duplicate: :skip+ → MySQL
75
+ # +INSERT IGNORE+, PG/SQLite +ON CONFLICT DO NOTHING+).
76
+ update_columns = []
77
+ update_columns << :published_at unless published_at.nil?
78
+ update_columns << :unpublished_at unless unpublished_at.nil?
79
+ opts = if update_columns.empty?
80
+ { on_duplicate: :skip }
81
+ else
82
+ { record_timestamps: false, update_only: update_columns, on_duplicate: :update }
83
+ end
84
+ # MySQL infers the conflict target from the table's unique constraints
85
+ # and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
86
+ opts[:unique_by] = %i[product_id channel_id] unless mysql_adapter?
87
+
88
+ Spree::ProductPublication.upsert_all(records_to_upsert, **opts)
89
+
90
+ products = Spree::Product.where(id: product_ids)
91
+ products.touch_all
92
+ products.each(&:enqueue_search_index)
93
+ touch
94
+
95
+ records_to_upsert.size
96
+ end
97
+
98
+ # Unpublishes the given products from this channel.
99
+ # @param product_ids [Array<Integer>, Integer] the IDs of the products to unpublish
100
+ # @return [Integer] the number of ProductPublications destroyed
101
+ def remove_products(product_ids)
102
+ product_ids = Array(product_ids).map(&:to_s).uniq
103
+ return 0 if product_ids.empty?
104
+
105
+ count = publications.where(product_id: product_ids).destroy_all.size
106
+
107
+ products = Spree::Product.where(id: product_ids)
108
+ products.touch_all
109
+ products.each(&:enqueue_search_index)
110
+
111
+ touch if count.positive?
112
+ count
113
+ end
114
+
115
+ # @return [Boolean]
116
+ def can_be_deleted?
117
+ !default?
118
+ end
119
+
120
+ private
121
+
122
+ def ensure_not_default
123
+ return if can_be_deleted?
124
+ return if destroyed_by_association.present?
125
+
126
+ errors.add(:base, Spree.t('errors.messages.cannot_delete_default_channel'))
127
+ throw :abort
128
+ end
129
+
130
+ def backfill_code_from_name
131
+ self.code = name
132
+ end
133
+
134
+ # First channel on a store becomes the default. Lets the
135
+ # +Stores::Channels#ensure_default_channel+ seed path and the legacy
136
+ # admin "create channel" form both produce a sensible default without
137
+ # the caller having to know.
138
+ def promote_first_channel_to_default
139
+ return if default
140
+ return unless new_record? && store_id.present?
141
+
142
+ self.default = true unless Spree::Channel.where(store_id: store_id).exists?
143
+ end
144
+
145
+ def demote_other_defaults
146
+ Spree::Channel.where(store_id: store_id, default: true).where.not(id: id).update_all(default: false)
147
+ end
148
+
149
+ # Default ordering: preferred location wins, then minimize splits, then
150
+ # fall back to StockLocation.default. See docs/plans/6.0-order-routing.md.
151
+ def ensure_default_order_routing_rules
152
+ return if order_routing_rules.any?
153
+
154
+ Spree::OrderRouting::Rules::PreferredLocation.create!(store: store, channel: self, position: 1)
155
+ Spree::OrderRouting::Rules::MinimizeSplits.create!(store: store , channel: self, position: 2)
156
+ Spree::OrderRouting::Rules::DefaultLocation.create!(store: store, channel: self, position: 3)
157
+ end
158
+ end
159
+ end
@@ -1,5 +1,7 @@
1
1
  module Spree
2
2
  class Country < Spree.base_class
3
+ self.whitelisted_ransackable_attributes = %w[iso iso3 iso_name name]
4
+
3
5
  has_many :addresses, dependent: :restrict_with_error
4
6
  has_many :states,
5
7
  -> { order name: :asc },
@@ -4,7 +4,7 @@ module Spree
4
4
  # All attributes are automatically reset between requests by Rails.
5
5
  # Fallback chains ensure sensible defaults when attributes are not explicitly set.
6
6
  class Current < ::ActiveSupport::CurrentAttributes
7
- attribute :store, :market, :currency, :locale, :zone, :default_tax_zone, :price_lists, :global_pricing_context
7
+ attribute :store, :channel, :market, :currency, :locale, :zone, :default_tax_zone, :price_lists, :global_pricing_context
8
8
 
9
9
  resets { @default_tax_zone_loaded = false }
10
10
 
@@ -14,6 +14,10 @@ module Spree
14
14
  super || Spree::Store.default
15
15
  end
16
16
 
17
+ def channel
18
+ super || (self.channel = store&.default_channel)
19
+ end
20
+
17
21
  # Returns the current market, falling back to the store's default market.
18
22
  # @return [Spree::Market, nil]
19
23
  def market
@@ -0,0 +1,9 @@
1
+ module Spree
2
+ # Constant alias for the legacy Spree::Metafield class. Lets controllers,
3
+ # serializers, and 5.5+ extensions reference the model by its 6.0-bound name
4
+ # without the actual class rename (which lands with the table rename in 6.0).
5
+ #
6
+ # This is a true constant alias — the underlying class, table, STI subclasses,
7
+ # and `model_name` are all `Spree::Metafield`. Only the constant name differs.
8
+ CustomField = Metafield
9
+ end
@@ -0,0 +1,7 @@
1
+ module Spree
2
+ # Constant alias for the legacy Spree::MetafieldDefinition class. Lets
3
+ # controllers, serializers, and 5.5+ extensions reference the model by its
4
+ # 6.0-bound name without the actual class rename (which lands with the table
5
+ # rename in 6.0).
6
+ CustomFieldDefinition = MetafieldDefinition
7
+ end
@@ -10,6 +10,11 @@ module Spree
10
10
  belongs_to :store, class_name: 'Spree::Store', inverse_of: :customer_groups
11
11
  has_many :customer_group_users, class_name: 'Spree::CustomerGroupUser', dependent: :destroy
12
12
  has_many :users, through: :customer_group_users, source: :user, source_type: Spree.user_class.to_s
13
+ # `customers` is the public name across the v3 API; declaring it as its
14
+ # own association (rather than `alias_method`) is what lets `customer_ids=`
15
+ # exist and what makes the `PrefixedId` auto-decoder in `assign_attributes`
16
+ # recognise the key — that lookup keys off `reflect_on_association(:customers)`.
17
+ has_many :customers, through: :customer_group_users, source: :user, source_type: Spree.user_class.to_s
13
18
 
14
19
  #
15
20
  # Validations
@@ -25,9 +30,10 @@ module Spree
25
30
  #
26
31
  # Instance Methods
27
32
  #
28
- def users_count
29
- customer_group_users.count
33
+ def customers_count
34
+ customer_group_users.size
30
35
  end
36
+ alias_method :users_count, :customers_count
31
37
 
32
38
  # Bulk add customers to the group
33
39
  # @param user_ids [Array] array of user IDs to add
@@ -22,13 +22,16 @@ module Spree
22
22
  # Associations
23
23
  #
24
24
  belongs_to :store, class_name: 'Spree::Store'
25
- belongs_to :user, class_name: Spree.admin_user_class.to_s
25
+ # Optional so secret-API-key callers (apps / server-to-server) can create
26
+ # exports without a human user attached. The email notification is
27
+ # skipped for these — apps poll instead.
28
+ belongs_to :user, class_name: Spree.admin_user_class.to_s, optional: true
26
29
  belongs_to :vendor, -> { with_deleted }, class_name: 'Spree::Vendor', optional: true
27
30
 
28
31
  #
29
32
  # Validations
30
33
  #
31
- validates :format, :store, :user, :type, presence: true
34
+ validates :format, :store, :type, presence: true
32
35
 
33
36
  #
34
37
  # Enums
@@ -127,12 +130,34 @@ module Spree
127
130
 
128
131
  def records_to_export
129
132
  if search_params.present?
130
- scope.ransack(search_params.is_a?(String) ? JSON.parse(search_params.to_s).to_h : search_params)
133
+ params = search_params.is_a?(String) ? JSON.parse(search_params.to_s).to_h : search_params
134
+ scope.ransack(decode_prefixed_id_filters(params))
131
135
  else
132
136
  scope.ransack
133
137
  end.result
134
138
  end
135
139
 
140
+ # Replace any prefixed IDs in `search_params` with their raw DB IDs so
141
+ # Ransack can match them. Without this, an admin filtering an export by
142
+ # a foreign key (`promotion_id_eq: 'promo_xxx'`, `vendor_id_in: [...]`)
143
+ # would always get zero rows. We only touch values that look like
144
+ # prefixed IDs — anything else (numeric IDs, code strings, ranges,
145
+ # state names) passes through untouched.
146
+ def decode_prefixed_id_filters(params)
147
+ params.transform_values { |value| decode_search_value(value) }
148
+ end
149
+
150
+ def decode_search_value(value)
151
+ case value
152
+ when String
153
+ Spree::PrefixedId.prefixed_id?(value) ? (Spree::PrefixedId.decode_prefixed_id(value) || value) : value
154
+ when Array
155
+ value.map { |v| decode_search_value(v) }
156
+ else
157
+ value
158
+ end
159
+ end
160
+
136
161
  def scope_includes
137
162
  []
138
163
  end
@@ -181,6 +206,8 @@ module Spree
181
206
  end
182
207
 
183
208
  def send_export_done_email
209
+ return if user.blank? # App-created exports (secret API key) have no user to email.
210
+
184
211
  Spree::ExportMailer.export_done(self).deliver_later
185
212
  end
186
213
 
@@ -6,6 +6,31 @@ module Spree
6
6
 
7
7
  validates :type, presence: true, inclusion: { in: :valid_providers_list }
8
8
 
9
+ # Payment provider gems conventionally ship a top-level `Gateway`
10
+ # class — `SpreeStripe::Gateway`, `SpreeAdyen::Gateway`,
11
+ # `SpreePaypalCheckout::Gateway`. The default demodulized
12
+ # `api_type` collapses every provider to `"gateway"`, which
13
+ # collides in the registry and produces duplicate keys in admin
14
+ # UIs. For gateway subclasses, use the outer module instead (with
15
+ # a leading `Spree` namespace stripped), matching the labelling
16
+ # convention in `PreferenceSchema#subclass_label`:
17
+ #
18
+ # SpreeStripe::Gateway → "stripe"
19
+ # SpreeAdyen::Gateway → "adyen"
20
+ # SpreePaypalCheckout::Gateway → "paypal_checkout"
21
+ # MyShop::Gateway → "my_shop"
22
+ #
23
+ # Subclasses nested under a Gateway module (e.g.
24
+ # `Spree::Gateway::Bogus`) demodulize to their own leaf and
25
+ # bypass this fallback.
26
+ def self.api_type
27
+ leaf = super
28
+ return leaf unless leaf == 'gateway'
29
+
30
+ outer = to_s.deconstantize.delete_prefix('Spree::').delete_prefix('Spree').presence
31
+ outer ? outer.underscore : leaf
32
+ end
33
+
9
34
  def payment_source_class
10
35
  CreditCard
11
36
  end
@@ -62,7 +62,7 @@ module Spree
62
62
  #
63
63
  # Ransack
64
64
  #
65
- self.whitelisted_ransackable_attributes = %w[code user_id state]
65
+ self.whitelisted_ransackable_attributes = %w[code user_id state gift_card_batch_id created_by_id]
66
66
  self.whitelisted_ransackable_associations = %w[users orders batch]
67
67
  self.whitelisted_ransackable_scopes = %w[active expired redeemed partially_redeemed]
68
68
 
@@ -18,7 +18,10 @@ module Spree
18
18
  # Validations
19
19
  #
20
20
  validates :codes_count, :amount, :prefix, presence: true
21
- validates :codes_count, numericality: { greater_than: 0, less_than_or_equal_to: Spree::Config[:gift_card_batch_limit].to_i }
21
+ validates :codes_count, numericality: {
22
+ greater_than: 0,
23
+ less_than_or_equal_to: ->(_record) { Spree::Config[:gift_card_batch_limit].to_i }
24
+ }
22
25
  validates :store, :currency, presence: true
23
26
  validates :amount, numericality: { greater_than: 0 }
24
27
 
@@ -62,6 +62,7 @@ module Spree
62
62
  event :complete do
63
63
  transition from: :processing, to: :completed
64
64
  end
65
+ after_transition to: :completed, do: :touch_store
65
66
  after_transition to: :completed, do: :publish_import_completed_event
66
67
  # NOTE: send_import_completed_email and update_loader_in_import_view
67
68
  # are now handled by Spree::Admin::ImportSubscriber listening to 'import.completed' event
@@ -181,6 +182,10 @@ module Spree
181
182
  "#{Spree.t(type.demodulize.pluralize.downcase)} #{number}"
182
183
  end
183
184
 
185
+ def touch_store
186
+ store.touch
187
+ end
188
+
184
189
  def publish_import_completed_event
185
190
  publish_event('import.completed')
186
191
  end
@@ -41,6 +41,11 @@ module Spree
41
41
  # are now handled by Spree::Admin::ImportRowSubscriber
42
42
  end
43
43
 
44
+ # How long a row may sit in `processing` before we consider its owning worker dead
45
+ # (OOM, SIGKILL, deploy without graceful drain — none of these trigger a Sidekiq
46
+ # retry). After this window, stalled rows no longer block the import from completing.
47
+ STALLED_PROCESSING_AFTER = 1.hour
48
+
44
49
  #
45
50
  # Scopes
46
51
  #
@@ -48,6 +53,13 @@ module Spree
48
53
  scope :completed, -> { where(status: :completed) }
49
54
  scope :failed, -> { where(status: :failed) }
50
55
  scope :processed, -> { where(status: %i[completed failed]) }
56
+ # Rows still legitimately blocking import completion: `pending` (not started) or
57
+ # `processing` with a recent updated_at (worker still alive). Orphaned `processing`
58
+ # rows past the stall window are excluded so a dead worker can't permanently block
59
+ # completion — operators can clean those up separately.
60
+ scope :in_flight, -> {
61
+ where(status: :pending).or(where(status: :processing).where(updated_at: STALLED_PROCESSING_AFTER.ago..))
62
+ }
51
63
 
52
64
  def data_json
53
65
  @data_json ||= JSON.parse(data)
@@ -24,6 +24,7 @@ module Spree
24
24
  has_many :inventory_units, class_name: 'Spree::InventoryUnit', inverse_of: :line_item, dependent: :destroy
25
25
  has_many :shipments, through: :inventory_units, source: :shipment
26
26
  has_many :digital_links, dependent: :destroy
27
+ has_many :stock_reservations, class_name: 'Spree::StockReservation', inverse_of: :line_item, dependent: :destroy
27
28
 
28
29
  before_validation :copy_price
29
30
  before_validation :copy_tax_category
@@ -164,9 +165,13 @@ module Spree
164
165
 
165
166
  # Returns true if the line item has sufficient stock
166
167
  #
168
+ # The order's own active stock reservations are excluded from the
169
+ # availability check — a customer's own checkout hold must not make
170
+ # their own line item look out of stock.
171
+ #
167
172
  # @return [Boolean]
168
173
  def sufficient_stock?
169
- can_supply? quantity
174
+ Spree::Stock::Quantifier.new(variant, excluded_order: order).can_supply?(quantity)
170
175
  end
171
176
 
172
177
  # Returns true if the line item has insufficient stock
@@ -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
@@ -81,6 +81,37 @@ module Spree
81
81
  @supported_locales_list ||= (supported_locales.to_s.split(',').map(&:strip) << default_locale).compact.uniq.sort
82
82
  end
83
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
+
84
115
  # Returns true when the market is safe to delete. A market cannot be deleted
85
116
  # if it is the default market or the only market in the store, since
86
117
  # Spree::Current.currency would have no fallback.
@@ -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