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
@@ -40,6 +40,27 @@ module Spree
40
40
  super(attributes)
41
41
  end
42
42
 
43
+ # API-facing alias for the 6.0 rename (`5.4-6.0-custom-fields-rename.md`):
44
+ # callers see "custom fields" everywhere even though the underlying
45
+ # tables/columns still use `metafield*`. Reached via flat params on the
46
+ # admin API v3 (`custom_fields: [...]`); also works through
47
+ # `Model.new(permitted_params)` since Rails routes the key to this writer.
48
+ #
49
+ # Upsert semantics by `custom_field_definition_id`: existing entries
50
+ # for the same definition are updated, missing entries are created.
51
+ # Partial: definitions NOT in the array are left untouched, so the
52
+ # client can patch one field at a time without resending the rest.
53
+ # Blank values on an existing metafield destroy it (mirrors the dedicated
54
+ # endpoint's behavior via `metafields_attributes=`).
55
+ def custom_fields=(attributes)
56
+ return if attributes.blank?
57
+ return super(attributes) if attributes.first.is_a?(Spree::Metafield)
58
+
59
+ assign_custom_field_attrs(attributes)
60
+ end
61
+
62
+ after_save :apply_pending_custom_fields, if: -> { @pending_custom_field_attrs.present? }
63
+
43
64
  scope :with_metafield_key, ->(key_with_namespace) {
44
65
  namespace, key = extract_namespace_and_key(key_with_namespace)
45
66
  joins(metafields: :metafield_definition).where(spree_metafield_definitions: { namespace: namespace, key: key })
@@ -56,11 +77,44 @@ module Spree
56
77
  self.class.extract_namespace_and_key(key_with_namespace)
57
78
  end
58
79
 
59
- def set_metafield(key_with_namespace, value)
60
- namespace, key = extract_namespace_and_key(key_with_namespace)
61
- metafield_definition = Spree::MetafieldDefinition.find_or_create_by!(namespace: namespace, key: key, resource_type: self.class.name)
80
+ # Upsert a single custom field value on this resource. The first
81
+ # argument locates the definition by any of:
82
+ #
83
+ # - `"namespace.key"` string — auto-creates the definition if missing
84
+ # (backend-internal callers that don't know the id upfront).
85
+ # - {Spree::MetafieldDefinition} instance.
86
+ # - Integer / numeric String — raw definition id.
87
+ # - Prefixed-id String (`"cfdef_..."`) — decoded to the definition id.
88
+ #
89
+ # Blank values (nil or empty/whitespace string) destroy any existing
90
+ # metafield for the definition. Empty containers (`[]`, `{}`) and
91
+ # numeric / boolean falsy values are real values, not blanks.
92
+ #
93
+ # @param definition_or_key [String, Integer, Spree::MetafieldDefinition]
94
+ # @param value [Object] the value to persist; type is enforced by the
95
+ # typed metafield subclass (Boolean, Number, Json, ShortText, …).
96
+ # @return [Spree::Metafield, nil] the persisted metafield, or nil when
97
+ # the value was blank and any existing row was destroyed.
98
+ # @raise [ArgumentError] if `definition_or_key` doesn't resolve to a
99
+ # known definition.
100
+ def set_metafield(definition_or_key, value)
101
+ definition_id = resolve_metafield_definition_id(definition_or_key)
102
+ metafield = metafields.find_or_initialize_by(metafield_definition_id: definition_id)
103
+ if value_blank?(value)
104
+ metafield.destroy if metafield.persisted?
105
+ return nil
106
+ end
107
+
108
+ # JSON metafields store canonical JSON in the underlying text column.
109
+ # Coerce Hash/Array values BEFORE assignment, since the STI subclass
110
+ # (`Spree::Metafields::Json`) isn't switched on until before_validation,
111
+ # so its custom `value=` writer doesn't run yet on a fresh
112
+ # `find_or_initialize_by` record.
113
+ if (value.is_a?(Hash) || value.is_a?(Array)) &&
114
+ metafield.metafield_definition&.metafield_type == 'Spree::Metafields::Json'
115
+ value = value.to_json
116
+ end
62
117
 
63
- metafield = metafields.find_or_initialize_by(metafield_definition: metafield_definition)
64
118
  metafield.value = value
65
119
  metafield.save!
66
120
  metafield
@@ -86,8 +140,116 @@ module Spree
86
140
 
87
141
  private
88
142
 
143
+ # Decide whether a metafield value should trigger the destroy-existing
144
+ # branch. Ruby's `blank?` reports `false`, `0`, `[]`, `{}` as blank, but
145
+ # for typed metafields those are real values:
146
+ #
147
+ # - Boolean `false` / Numeric `0` — real values, never destroy.
148
+ # - Empty Array / Hash — a JSON metafield storing `[]` or `{}` is a
149
+ # meaningful value (an empty list / object), not a clear signal.
150
+ #
151
+ # Only `nil` and empty/whitespace strings count as "missing".
152
+ #
153
+ # @param value [Object]
154
+ # @return [Boolean]
89
155
  def value_blank?(value)
90
- value.blank?
156
+ return true if value.nil?
157
+ return value.strip.empty? if value.is_a?(String)
158
+
159
+ false
160
+ end
161
+
162
+ def assign_custom_field_attrs(attributes)
163
+ if new_record?
164
+ # Persisting metafields requires a persisted parent (resource_id NOT
165
+ # NULL). Stash the attrs and replay them after the parent is saved.
166
+ @pending_custom_field_attrs = attributes
167
+ return
168
+ end
169
+
170
+ apply_custom_field_attrs(attributes)
171
+ end
172
+
173
+ def apply_pending_custom_fields
174
+ attrs = @pending_custom_field_attrs
175
+ @pending_custom_field_attrs = nil
176
+ apply_custom_field_attrs(attrs)
177
+ end
178
+
179
+ def apply_custom_field_attrs(attributes)
180
+ attributes = attributes.values if attributes.is_a?(Hash)
181
+ attributes.each_with_index do |raw, index|
182
+ attrs = raw.respond_to?(:to_h) ? raw.to_h : raw
183
+ attrs = attrs.with_indifferent_access
184
+ definition_id = attrs[:metafield_definition_id] || attrs[:custom_field_definition_id]
185
+ next if definition_id.blank?
186
+
187
+ begin
188
+ set_metafield(definition_id, attrs[:value])
189
+ rescue ArgumentError => e
190
+ # Convert an unknown / malformed definition id into a field-level
191
+ # validation error so the controller returns 422 with structured
192
+ # `details`, instead of leaking ArgumentError as a 400/500.
193
+ errors.add("custom_fields[#{index}].custom_field_definition_id", e.message)
194
+ raise ActiveRecord::RecordInvalid, self
195
+ end
196
+ end
197
+ end
198
+
199
+ # Resolve any of the supported reference shapes to a raw definition id.
200
+ # See {#set_metafield} for the accepted shapes.
201
+ #
202
+ # @param definition_or_key [String, Integer, Spree::MetafieldDefinition]
203
+ # @return [Integer, String] the definition's primary key value (Integer
204
+ # for legacy integer-id setups, String for UUID setups).
205
+ # @raise [ArgumentError] for unknown / malformed input.
206
+ def resolve_metafield_definition_id(definition_or_key)
207
+ case definition_or_key
208
+ when Spree::MetafieldDefinition
209
+ definition_or_key.id
210
+ when Integer
211
+ definition_or_key
212
+ when String
213
+ resolve_metafield_definition_id_from_string(definition_or_key)
214
+ else
215
+ raise ArgumentError, "Invalid definition_or_key: #{definition_or_key.inspect}"
216
+ end
217
+ end
218
+
219
+ # @param value [String] one of: `"namespace.key"`, a prefixed id
220
+ # (`"cfdef_..."`), or a bare numeric id (`"42"`).
221
+ # @return [Integer, String] the resolved definition's primary key value.
222
+ # @raise [ArgumentError] if the string doesn't match any known shape.
223
+ def resolve_metafield_definition_id_from_string(value)
224
+ # `"namespace.key"` — backend-internal callers that don't know the id;
225
+ # auto-create the definition if missing.
226
+ if value.include?('.')
227
+ namespace, key = extract_namespace_and_key(value)
228
+ return Spree::MetafieldDefinition.find_or_create_by!(
229
+ namespace: namespace, key: key, resource_type: self.class.name
230
+ ).id
231
+ end
232
+
233
+ # Prefixed id (`"cfdef_..."`/`"mfd_..."`). Use the canonical predicate
234
+ # so single-segment names with underscores (e.g. `"product_specs"`)
235
+ # don't get mistaken for prefixed ids. We must verify the decoded id
236
+ # actually exists — Sqids will happily decode any all-lowercase
237
+ # alphanumeric string to a phantom integer; without the existence
238
+ # check `find_or_initialize_by(metafield_definition_id: phantom_id)`
239
+ # would later raise a confusing "Metafield definition must exist"
240
+ # 422 instead of an "unknown id" 422.
241
+ if Spree::PrefixedId.prefixed_id?(value)
242
+ decoded = Spree::MetafieldDefinition.decode_prefixed_id(value)
243
+ existing = decoded && Spree::MetafieldDefinition.find_by(id: decoded)
244
+ raise ArgumentError, "Unknown metafield definition id: #{value.inspect}" if existing.nil?
245
+
246
+ return existing.id
247
+ end
248
+
249
+ # Bare numeric id ("42"). Reject anything else outright.
250
+ raise ArgumentError, "Invalid metafield definition reference: #{value.inspect}" unless /\A\d+\z/.match?(value)
251
+
252
+ value.to_i
91
253
  end
92
254
  end
93
255
  end
@@ -0,0 +1,191 @@
1
+ module Spree
2
+ # Adds class-level helpers that surface a JSON-friendly description of
3
+ # a `Preferable` class's `preference :name, :type, default:` declarations.
4
+ #
5
+ # Used by the admin API (`/payment_methods/types`, `/promotion_actions/types`,
6
+ # `/promotion_rules/types`) so that admin UIs can render configuration forms
7
+ # for any provider/action/rule subclass without hard-coding field lists.
8
+ module PreferenceSchema
9
+ extend ActiveSupport::Concern
10
+
11
+ delegate :preference_schema, :serialized_preference_schema, :password_preference_keys, to: :class
12
+
13
+ # Wire-safe view of `preferences` with `:password`-typed values
14
+ # masked. Lives here (not in the serializer) so any consumer holding
15
+ # a Preferable instance gets the same safety guarantee — secrets
16
+ # must never leave the server in plaintext. Keys are stringified to
17
+ # match the wire shape expected by JSON clients.
18
+ #
19
+ # @return [Hash{String => Object}]
20
+ def serialized_preferences
21
+ Spree::Preferences::Masking.serialize(self)
22
+ end
23
+
24
+ class_methods do
25
+ # Returns `[{ key:, type:, default: }]` for every preference declared
26
+ # on this class (and its ancestors). Skips deprecated preferences.
27
+ #
28
+ # Memoized at class load — the schema is derived from the static
29
+ # `preference :name, :type` declarations, so it can never change at
30
+ # runtime. Each entry also caches `key_string` (frozen) so hot-path
31
+ # serializers don't allocate `pref.to_s` per request.
32
+ def preference_schema
33
+ @preference_schema ||= compute_preference_schema
34
+ end
35
+
36
+ # Wire-safe variant of `preference_schema` with `:password`
37
+ # defaults nilled out. A gateway author can set a non-empty
38
+ # default for a `:password` preference; without this redaction the
39
+ # default leaks alongside the masked live value. Memoized so admin
40
+ # index responses don't re-allocate per row.
41
+ #
42
+ # Strips `:key_string` — that's a server-only cache used by
43
+ # `Masking.serialize` to avoid `to_s` allocations per request, not
44
+ # part of the documented `{ key, type, default }` wire shape.
45
+ def serialized_preference_schema
46
+ @serialized_preference_schema ||= preference_schema.map do |field|
47
+ wire = { key: field[:key], type: field[:type], default: field[:default] }
48
+ wire[:default] = nil if field[:type] == :password
49
+ wire.freeze
50
+ end.freeze
51
+ end
52
+
53
+ # Set of `:password`-typed preference keys for this class. Memoized
54
+ # so write-side guards (e.g. the masked-round-trip check) don't
55
+ # walk the schema or fall back to a `rescue NoMethodError`.
56
+ def password_preference_keys
57
+ @password_preference_keys ||= preference_schema
58
+ .each_with_object(Set.new) { |field, set| set << field[:key] if field[:type] == :password }
59
+ .freeze
60
+ end
61
+
62
+ def compute_preference_schema
63
+ instance = new
64
+ instance.defined_preferences.filter_map do |pref|
65
+ next if instance.preference_deprecated(pref)
66
+
67
+ {
68
+ key: pref,
69
+ key_string: pref.to_s.freeze,
70
+ type: instance.preference_type(pref),
71
+ default: safe_preference_default(instance, pref)
72
+ }.freeze
73
+ end
74
+ rescue StandardError
75
+ []
76
+ end
77
+
78
+ # Builds a `parse_on_set:` lambda for `preference :foo_ids, :array`
79
+ # declarations that accept prefixed IDs (e.g. `cg_…`, `mkt_…`) from
80
+ # the API. Splits comma-separated entries, strips whitespace, and
81
+ # decodes any prefixed IDs to raw IDs so eligibility checks compare
82
+ # against `belongs_to` foreign keys directly.
83
+ #
84
+ # When `klass` is nil, prefixed-ID decoding is skipped — used for
85
+ # ISO/string-keyed preferences where the value is the identifier
86
+ # (e.g. country `:country_isos`).
87
+ #
88
+ # When `scope:` is given, the existence check runs through the
89
+ # scope relation derived from the owning record — prevents a
90
+ # rule from being persisted with IDs that belong to another
91
+ # store (e.g. a Market rule referencing markets from a different
92
+ # store). The proc receives the rule instance.
93
+ #
94
+ # @param klass [Class<Spree::Base>, nil] AR class used to resolve
95
+ # prefixed IDs via Sqids decoding + a single existence check.
96
+ # @param scope [Proc, nil] optional `->(rule) { rule.price_list.store.markets }`
97
+ # relation builder; defaults to the unscoped `klass`.
98
+ # @return [Proc] suitable for the `parse_on_set:` preference option.
99
+ def normalize_id_preference(klass: nil, scope: nil)
100
+ lambda do |values, owner = nil|
101
+ raw = Array(values).flat_map { |v| v.to_s.split(',') }.compact_blank.map(&:strip)
102
+ next raw unless klass
103
+
104
+ decoded = raw.map do |v|
105
+ Spree::PrefixedId.prefixed_id?(v) ? Spree::PrefixedId.decode_prefixed_id(v).to_s : v
106
+ end
107
+
108
+ relation = scope && owner ? scope.call(owner) : klass
109
+ found = relation.where(id: decoded).pluck(:id).map(&:to_s).to_set
110
+ missing = decoded.reject { |id| found.include?(id) }
111
+ raise ActiveRecord::RecordNotFound.new(
112
+ "Couldn't find #{klass.name} with id=#{missing.join(',')}", klass.name
113
+ ) if missing.any?
114
+
115
+ decoded
116
+ end
117
+ end
118
+
119
+ # Resolve a wire-format shorthand back to its registered subclass.
120
+ # Returns nil for unknown shorthands. Lookup is registry-driven so
121
+ # removed/foreign subclasses can't be smuggled in.
122
+ def find_by_api_type(shorthand)
123
+ return nil if shorthand.blank?
124
+
125
+ registered_subclasses.find { |klass| klass.api_type == shorthand.to_s }
126
+ end
127
+
128
+ # Returns a `[{ type:, label:, description:, preference_schema: }]`
129
+ # array for every concrete subclass in `subclasses`. Sorted by label
130
+ # for stable output. Uses `serialized_preference_schema` so
131
+ # `:password` defaults are redacted — `/types` is an unauthenticated
132
+ # discovery surface and must never leak gateway-shipped defaults.
133
+ def subclasses_with_preference_schema
134
+ registered_subclasses.map do |klass|
135
+ {
136
+ type: klass.api_type,
137
+ label: subclass_label(klass),
138
+ description: klass.respond_to?(:description) ? klass.description : nil,
139
+ preference_schema: klass.respond_to?(:serialized_preference_schema) ? klass.serialized_preference_schema : []
140
+ }
141
+ end.sort_by { |entry| entry[:label] }
142
+ end
143
+
144
+ # STI subclasses share the parent's `model_name`, so calling
145
+ # `klass.model_name.human` would return "Payment Method" for every
146
+ # entry. Subclasses can override by defining a class-level
147
+ # `display_name`. Otherwise:
148
+ #
149
+ # Spree::PaymentMethod::Check → "Check"
150
+ # Spree::Gateway::Bogus → "Bogus"
151
+ # SpreeStripe::Gateway → "Stripe"
152
+ # SpreeAdyen::Gateway → "Adyen"
153
+ #
154
+ # The "Gateway" branch handles the gem convention where each
155
+ # provider gem ships a top-level `Gateway` class (so demodulize
156
+ # would collapse them all to "Gateway"). Fall back to the outer
157
+ # module, with a leading `Spree` namespace stripped.
158
+ def subclass_label(klass)
159
+ return klass.display_name if klass.respond_to?(:display_name) && klass.display_name.present?
160
+ return klass.human_name if klass.respond_to?(:human_name) && klass.human_name.present?
161
+
162
+ leaf = klass.to_s.demodulize
163
+ return leaf.titleize unless leaf == 'Gateway'
164
+
165
+ outer = klass.to_s.split('::').first.to_s
166
+ outer.delete_prefix('Spree').presence&.titleize || leaf.titleize
167
+ end
168
+
169
+ private
170
+
171
+ # Each STI parent (PaymentMethod, PromotionAction, PromotionRule)
172
+ # already exposes its registry — we just route to the right one.
173
+ # Override in the including class to add support for custom parents.
174
+ def registered_subclasses
175
+ return providers if respond_to?(:providers)
176
+ return Spree.promotions.actions if name == 'Spree::PromotionAction'
177
+ return Spree.promotions.rules if name == 'Spree::PromotionRule'
178
+
179
+ []
180
+ end
181
+
182
+ # Defaults can be Procs that hit the database (e.g. `Spree::Store.default`);
183
+ # those aren't safe to evaluate at request time, so we stringify them.
184
+ def safe_preference_default(instance, pref)
185
+ instance.preference_default(pref)
186
+ rescue StandardError
187
+ nil
188
+ end
189
+ end
190
+ end
191
+ end
@@ -20,6 +20,35 @@ module Spree
20
20
  class_attribute :_prefix_id_prefix, instance_writer: false
21
21
  end
22
22
 
23
+ # Automatically resolve prefixed ID strings for belongs_to foreign keys.
24
+ # e.g., product.assign_attributes(tax_category_id: "tc_86Rf07xd4z") will
25
+ # decode the prefixed ID to the integer primary key.
26
+ def assign_attributes(new_attributes)
27
+ return super if new_attributes.blank?
28
+
29
+ attrs = new_attributes.to_h
30
+ needs_resolution = attrs.any? do |key, value|
31
+ key_s = key.to_s
32
+ (value.is_a?(String) && key_s.end_with?('_id') && Spree::PrefixedId.prefixed_id?(value)) ||
33
+ (value.is_a?(Array) && key_s.end_with?('_ids') && value.any? { |v| Spree::PrefixedId.prefixed_id?(v) })
34
+ end
35
+
36
+ return super unless needs_resolution
37
+
38
+ resolved = attrs.each_with_object({}.with_indifferent_access) do |(key, value), hash|
39
+ key_s = key.to_s
40
+ if value.is_a?(String) && key_s.end_with?('_id') && Spree::PrefixedId.prefixed_id?(value)
41
+ hash[key] = self.class.resolve_prefixed_id_for_attribute(key_s, value)
42
+ elsif value.is_a?(Array) && key_s.end_with?('_ids')
43
+ hash[key] = self.class.resolve_prefixed_ids_for_attribute(key_s, value)
44
+ else
45
+ hash[key] = value
46
+ end
47
+ end
48
+
49
+ super(resolved)
50
+ end
51
+
23
52
  # Returns the Stripe-style prefixed ID, or nil for unsaved records.
24
53
  def prefixed_id
25
54
  return nil unless id.present?
@@ -36,35 +65,89 @@ module Spree
36
65
  prefixed_id.presence || super
37
66
  end
38
67
 
68
+ # Module-level methods for use without a model context (e.g., from ParamsNormalizer)
69
+ def self.prefixed_id?(value)
70
+ value.is_a?(String) && value.match?(/\A[a-z]+_[a-zA-Z0-9]+\z/)
71
+ end
72
+
73
+ def self.decode_prefixed_id(prefixed_id_string)
74
+ return nil if prefixed_id_string.blank?
75
+
76
+ parts = prefixed_id_string.to_s.split('_', 2)
77
+ return nil if parts.length != 2
78
+
79
+ _prefix, encoded = parts
80
+ ids = SQIDS.decode(encoded)
81
+ ids.first
82
+ end
83
+
39
84
  class_methods do
40
85
  def has_prefix_id(prefix)
41
86
  self._prefix_id_prefix = prefix.to_s
42
87
  end
43
88
 
89
+ def prefixed_id?(value)
90
+ Spree::PrefixedId.prefixed_id?(value)
91
+ end
92
+
93
+ # Memoized map of foreign_key → belongs_to reflection for prefixed ID resolution.
94
+ def belongs_to_reflections_by_fk
95
+ @belongs_to_reflections_by_fk ||= reflect_on_all_associations(:belongs_to)
96
+ .reject(&:polymorphic?)
97
+ .index_by { |a| a.foreign_key.to_s }
98
+ end
99
+
100
+ # Resolve a prefixed ID string for a belongs_to foreign key attribute.
101
+ # Uses the association's target class to validate the record exists.
102
+ # Only resolves when a matching belongs_to association exists — columns
103
+ # like external_id that happen to end with _id are left untouched.
104
+ # Resolves aliased FKs (via alias_attribute) to their canonical name.
105
+ def resolve_prefixed_id_for_attribute(attribute_name, prefixed_id_value)
106
+ canonical_name = attribute_aliases[attribute_name] || attribute_name
107
+ reflection = belongs_to_reflections_by_fk[canonical_name]
108
+
109
+ if reflection
110
+ reflection.klass.find_by_param!(prefixed_id_value).id
111
+ else
112
+ prefixed_id_value
113
+ end
114
+ end
115
+
116
+ # Resolve an array of prefixed IDs for a has_many _ids setter.
117
+ # Infers the target class from the association name (e.g., taxon_ids → taxons → Spree::Taxon).
118
+ # Only resolves when a matching association exists.
119
+ def resolve_prefixed_ids_for_attribute(attribute_name, values)
120
+ association_name = attribute_name.sub(/_ids$/, '').pluralize
121
+ reflection = reflect_on_association(association_name.to_sym)
122
+ klass = reflection&.klass
123
+
124
+ return values unless klass
125
+
126
+ values.map do |v|
127
+ if Spree::PrefixedId.prefixed_id?(v)
128
+ klass.find_by_param!(v).id
129
+ else
130
+ v
131
+ end
132
+ end
133
+ end
134
+
44
135
  def find_by_prefix_id!(prefixed_id)
45
- decoded = decode_prefixed_id(prefixed_id)
136
+ decoded = Spree::PrefixedId.decode_prefixed_id(prefixed_id)
46
137
  raise ActiveRecord::RecordNotFound.new("Couldn't find #{name} with prefixed id=#{prefixed_id}", name) unless decoded
47
138
 
48
139
  find(decoded)
49
140
  end
50
141
 
51
142
  def find_by_prefix_id(prefixed_id)
52
- decoded = decode_prefixed_id(prefixed_id)
143
+ decoded = Spree::PrefixedId.decode_prefixed_id(prefixed_id)
53
144
  return nil unless decoded
54
145
 
55
146
  find_by(id: decoded)
56
147
  end
57
148
 
58
- # Decode a prefixed ID string (e.g., "prod_86Rf07xd4z") to the integer primary key.
59
149
  def decode_prefixed_id(prefixed_id_string)
60
- return nil if prefixed_id_string.blank?
61
-
62
- parts = prefixed_id_string.to_s.split('_', 2)
63
- return nil if parts.length != 2
64
-
65
- _prefix, encoded = parts
66
- ids = Spree::PrefixedId::SQIDS.decode(encoded)
67
- ids.first
150
+ Spree::PrefixedId.decode_prefixed_id(prefixed_id_string)
68
151
  end
69
152
 
70
153
  # Find by prefixed ID first, falling back to integer id for backwards compatibility.
@@ -286,11 +286,12 @@ module Spree
286
286
  }
287
287
 
288
288
  def self.not_discontinued(only_not_discontinued = true)
289
- if only_not_discontinued != '0' && only_not_discontinued
290
- where(discontinue_on: [nil, Time.current.beginning_of_minute..])
291
- else
292
- all
293
- end
289
+ return all if only_not_discontinued == '0' || !only_not_discontinued
290
+
291
+ channel = Spree::Current.channel
292
+ return where(discontinue_on: [nil, Time.current.beginning_of_minute..]) unless channel
293
+
294
+ for_channel(channel).where(Spree::ProductPublication.table_name => { unpublished_at: [nil, Time.current.beginning_of_minute..] })
294
295
  end
295
296
 
296
297
  def self.with_currency(currency)
@@ -300,11 +301,26 @@ module Spree
300
301
  distinct
301
302
  end
302
303
 
304
+ # @param available_on [Time, nil] cutoff for the published_at filter.
305
+ # When passed, products are only included if their current-channel
306
+ # publication's +published_at+ is at or before this time. When +nil+,
307
+ # published_at is not filtered — matches legacy semantics where
308
+ # +.available+ without args returned future-dated products too.
309
+ # @param currency [String, nil] currency to require a price in; nil
310
+ # falls back to the default store's default currency.
303
311
  def self.available(available_on = nil, currency = nil)
312
+ cutoff = available_on
313
+ cutoff = cutoff.beginning_of_minute if cutoff.respond_to?(:beginning_of_minute)
314
+
304
315
  scope = not_discontinued.where(status: 'active')
305
- if available_on
306
- available_on = available_on.beginning_of_minute if available_on.respond_to?(:beginning_of_minute)
307
- scope = scope.where("#{Product.quoted_table_name}.available_on <= ?", available_on)
316
+
317
+ if cutoff
318
+ scope = if (channel = Spree::Current.channel)
319
+ scope.for_channel(channel)
320
+ .where(Spree::ProductPublication.table_name => { published_at: [nil, ..cutoff] })
321
+ else
322
+ scope.where(Product.table_name => { available_on: ..cutoff })
323
+ end
308
324
  end
309
325
 
310
326
  unless Spree::Config.show_products_without_price
@@ -315,6 +331,10 @@ module Spree
315
331
  scope
316
332
  end
317
333
 
334
+ def self.for_channel(channel)
335
+ joins(:product_publications).where(Spree::ProductPublication.table_name => { channel_id: channel.id })
336
+ end
337
+
318
338
  def self.active(currency = nil)
319
339
  available(nil, currency)
320
340
  end
@@ -343,19 +363,18 @@ module Spree
343
363
  group('spree_products.id').joins(:taxons).where(Taxon.arel_table[:name].eq(name))
344
364
  end
345
365
 
346
- # Orders products by best selling based on units_sold_count and revenue
347
- # from spree_products_stores (already joined via store.products).
348
- #
349
- # Uses Arel::Nodes::As so that ORDER BY expressions appear in SELECT
350
- # and work with DISTINCT (same pattern as the price sorting scopes).
366
+ # Orders products by best-selling metrics (+units_sold_count+, +revenue+)
367
+ # tracked directly on +spree_products+. Uses Arel::Nodes::As so that
368
+ # ORDER BY expressions appear in SELECT and work with DISTINCT (same
369
+ # pattern as the price sorting scopes).
351
370
  scope :by_best_selling, ->(order_direction = :desc) {
352
- sp_table = StoreProduct.table_name
353
- units_expr = Arel.sql("COALESCE(#{sp_table}.units_sold_count, 0)")
354
- revenue_expr = Arel.sql("COALESCE(#{sp_table}.revenue, 0)")
371
+ p_table = Product.table_name
372
+ units_expr = Arel.sql("COALESCE(#{p_table}.units_sold_count, 0)")
373
+ revenue_expr = Arel.sql("COALESCE(#{p_table}.revenue, 0)")
355
374
 
356
375
  order_dir = order_direction == :desc ? :desc : :asc
357
376
 
358
- select("#{Product.table_name}.*").
377
+ select("#{p_table}.*").
359
378
  select(Arel::Nodes::As.new(units_expr, Arel.sql('best_selling_units'))).
360
379
  select(Arel::Nodes::As.new(revenue_expr, Arel.sql('best_selling_revenue'))).
361
380
  order(units_expr.send(order_dir)).
@@ -6,7 +6,11 @@ module Spree::RansackableAttributes
6
6
  class_attribute :whitelisted_ransackable_scopes
7
7
 
8
8
  class_attribute :default_ransackable_attributes
9
- self.default_ransackable_attributes = %w[id name updated_at created_at]
9
+ # `position` is included so any `acts_as_list` model is sortable by it
10
+ # without each subclass having to opt in. Ransack ignores attributes
11
+ # the model doesn't actually expose, so this is a no-op for tables
12
+ # without a position column.
13
+ self.default_ransackable_attributes = %w[id name updated_at created_at position]
10
14
 
11
15
  def self.ransackable_associations(*_args)
12
16
  base = whitelisted_ransackable_associations || []
@@ -68,14 +68,15 @@ module Spree
68
68
  false
69
69
  end
70
70
 
71
+ # Stores against which this record should be indexed/removed. By default
72
+ # we use the record's own +store_id+ (Product, Customer, Order, …). The
73
+ # multi-store extension overrides this to fan out across every store the
74
+ # record is attached to. Falling back to +Spree::Store.default+ covers
75
+ # records like categories/taxonomies that don't have a +store_id+ column.
71
76
  def store_ids_for_indexing
72
- if respond_to?(:store_ids)
73
- store_ids
74
- elsif respond_to?(:store_id)
75
- [store_id].compact
76
- else
77
- Spree::Store.pluck(:id)
78
- end
77
+ return [store_id] if respond_to?(:store_id) && store_id.present?
78
+
79
+ Array(Spree::Store.default&.id)
79
80
  end
80
81
  end
81
82
  end
@@ -11,9 +11,18 @@ module Spree
11
11
  encrypted_attributes = model_class.encrypted_attributes.presence || []
12
12
 
13
13
  if encrypted_attributes.include?(attribute.to_sym)
14
- model_class.arel_table[attribute.to_sym].eq(query)
14
+ # Encrypted columns can only be compared by equality — wildcard
15
+ # LIKE escapes would prevent the row from matching itself, so pass
16
+ # the raw query straight through.
17
+ model_class.arel_table[attribute.to_sym].eq(query.to_s.strip)
15
18
  else
16
- model_class.arel_table[attribute.to_sym].lower.matches("%#{query}%")
19
+ # Plain columns use case-insensitive LIKE. `sanitize_sql_like`
20
+ # escapes `_` and `%` so a query like `john_doe@example.com`
21
+ # doesn't have its underscore treated as a wildcard matching
22
+ # `john.doe@example.com`. Pass `\` as the ESCAPE character so
23
+ # SQLite/MySQL honor the escaping.
24
+ escaped = sanitize_query_for_search(query)
25
+ model_class.arel_table[attribute.to_sym].lower.matches("%#{escaped}%", '\\')
17
26
  end
18
27
  end
19
28