spree_core 6.0.0.rc1 → 6.0.0.rc2

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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/app/jobs/spree/search_provider/index_job.rb +4 -0
  4. data/app/jobs/spree/uploads/purge_unattached_job.rb +49 -0
  5. data/app/mailers/spree/base_mailer.rb +4 -3
  6. data/app/models/concerns/spree/admin_user_methods.rb +2 -0
  7. data/app/models/concerns/spree/calculated_adjustments.rb +41 -13
  8. data/app/models/concerns/spree/calculator/currency_amounts.rb +1 -1
  9. data/app/models/concerns/spree/calculator/tiers.rb +1 -14
  10. data/app/models/concerns/spree/canonical_decimals.rb +61 -0
  11. data/app/models/concerns/spree/customer_methods.rb +8 -4
  12. data/app/models/concerns/spree/derived_number.rb +11 -13
  13. data/app/models/concerns/spree/preference_schema/json_schema.rb +139 -0
  14. data/app/models/concerns/spree/preference_schema.rb +59 -21
  15. data/app/models/concerns/spree/product_scopes.rb +5 -1
  16. data/app/models/concerns/spree/purchase/payment_processing.rb +1 -1
  17. data/app/models/concerns/spree/purchase/purchase_order.rb +7 -2
  18. data/app/models/concerns/spree/purchase/store_credits.rb +36 -1
  19. data/app/models/concerns/spree/purchase/totals.rb +1 -8
  20. data/app/models/concerns/spree/ransackable_attributes.rb +46 -0
  21. data/app/models/concerns/spree/signed_id_attachments.rb +54 -0
  22. data/app/models/concerns/spree/stores/email_branding.rb +2 -1
  23. data/app/models/concerns/spree/stores/markets.rb +5 -2
  24. data/app/models/concerns/spree/typed_adjustment_line.rb +4 -5
  25. data/app/models/concerns/spree/typed_associations.rb +6 -18
  26. data/app/models/concerns/spree/vat_price_calculation.rb +5 -6
  27. data/app/models/spree/address.rb +1 -1
  28. data/app/models/spree/adjusters/largest_remainder.rb +14 -1
  29. data/app/models/spree/adjusters/promotion.rb +3 -4
  30. data/app/models/spree/allowed_origin.rb +3 -0
  31. data/app/models/spree/base.rb +29 -0
  32. data/app/models/spree/calculator/flat_percent_item_total.rb +1 -1
  33. data/app/models/spree/calculator/flat_rate.rb +2 -2
  34. data/app/models/spree/calculator/flexi_rate.rb +3 -3
  35. data/app/models/spree/calculator/percent_on_line_item.rb +1 -1
  36. data/app/models/spree/calculator/price_sack.rb +4 -4
  37. data/app/models/spree/calculator/shipping/digital_delivery.rb +2 -2
  38. data/app/models/spree/calculator/shipping/flat_percent_item_total.rb +6 -4
  39. data/app/models/spree/calculator/shipping/flat_rate.rb +4 -4
  40. data/app/models/spree/calculator/shipping/flexi_rate.rb +3 -3
  41. data/app/models/spree/calculator/shipping/per_item.rb +2 -2
  42. data/app/models/spree/calculator/shipping/price_sack.rb +4 -4
  43. data/app/models/spree/calculator/tiered_flat_rate.rb +2 -2
  44. data/app/models/spree/calculator/tiered_percent.rb +1 -1
  45. data/app/models/spree/catalog.rb +1 -0
  46. data/app/models/spree/catalog_price.rb +0 -7
  47. data/app/models/spree/catalog_price_tier.rb +0 -8
  48. data/app/models/spree/category.rb +3 -1
  49. data/app/models/spree/channel/gating.rb +2 -1
  50. data/app/models/spree/channel.rb +2 -0
  51. data/app/models/spree/claim.rb +1 -0
  52. data/app/models/spree/collection.rb +2 -0
  53. data/app/models/spree/commission_rate.rb +5 -1
  54. data/app/models/spree/commission_rules/category_rule.rb +2 -5
  55. data/app/models/spree/commission_rules/item_total_rule.rb +2 -2
  56. data/app/models/spree/commission_rules/seller_rule.rb +2 -5
  57. data/app/models/spree/company.rb +1 -0
  58. data/app/models/spree/custom_field_definition.rb +11 -12
  59. data/app/models/spree/customer_group.rb +1 -0
  60. data/app/models/spree/data_request.rb +1 -0
  61. data/app/models/spree/delivery_method.rb +6 -1
  62. data/app/models/spree/delivery_method_rules/channel_rule.rb +2 -5
  63. data/app/models/spree/delivery_method_rules/item_total_rule.rb +2 -2
  64. data/app/models/spree/delivery_profile.rb +1 -0
  65. data/app/models/spree/delivery_rate.rb +3 -3
  66. data/app/models/spree/delivery_zone.rb +1 -1
  67. data/app/models/spree/exchange.rb +1 -0
  68. data/app/models/spree/exports/report.rb +2 -2
  69. data/app/models/spree/file_upload.rb +155 -0
  70. data/app/models/spree/fulfillment.rb +13 -3
  71. data/app/models/spree/fulfillment_group.rb +6 -1
  72. data/app/models/spree/gateway/bogus.rb +2 -0
  73. data/app/models/spree/gateway.rb +1 -1
  74. data/app/models/spree/gift_card.rb +6 -7
  75. data/app/models/spree/gift_card_batch.rb +3 -4
  76. data/app/models/spree/import.rb +3 -0
  77. data/app/models/spree/integration.rb +1 -1
  78. data/app/models/spree/line_item.rb +2 -2
  79. data/app/models/spree/market.rb +7 -2
  80. data/app/models/spree/media.rb +1 -9
  81. data/app/models/spree/newsletter_subscriber.rb +1 -0
  82. data/app/models/spree/option_type.rb +2 -1
  83. data/app/models/spree/option_value.rb +2 -1
  84. data/app/models/spree/order.rb +5 -5
  85. data/app/models/spree/order_group.rb +7 -2
  86. data/app/models/spree/package_type.rb +1 -0
  87. data/app/models/spree/payment/gateway_options.rb +1 -1
  88. data/app/models/spree/payment.rb +3 -10
  89. data/app/models/spree/payment_method/check.rb +2 -0
  90. data/app/models/spree/payment_method/store_credit.rb +11 -9
  91. data/app/models/spree/payment_method.rb +42 -2
  92. data/app/models/spree/policy.rb +1 -0
  93. data/app/models/spree/price.rb +17 -14
  94. data/app/models/spree/price_adjustment_tier.rb +4 -0
  95. data/app/models/spree/price_list.rb +13 -7
  96. data/app/models/spree/price_rules/channel_rule.rb +2 -5
  97. data/app/models/spree/price_rules/customer_group_rule.rb +2 -5
  98. data/app/models/spree/price_rules/market_rule.rb +2 -5
  99. data/app/models/spree/price_rules/user_rule.rb +1 -3
  100. data/app/models/spree/product/channels.rb +9 -2
  101. data/app/models/spree/product.rb +23 -23
  102. data/app/models/spree/product_type.rb +1 -0
  103. data/app/models/spree/promotion/actions/create_line_items.rb +1 -1
  104. data/app/models/spree/promotion/rules/category.rb +1 -1
  105. data/app/models/spree/promotion/rules/channel.rb +2 -5
  106. data/app/models/spree/promotion/rules/country.rb +3 -4
  107. data/app/models/spree/promotion/rules/currency.rb +1 -1
  108. data/app/models/spree/promotion/rules/customer_group.rb +2 -5
  109. data/app/models/spree/promotion/rules/item_total.rb +2 -2
  110. data/app/models/spree/promotion/rules/market.rb +2 -5
  111. data/app/models/spree/promotion/rules/option_value.rb +2 -3
  112. data/app/models/spree/promotion/rules/product.rb +1 -1
  113. data/app/models/spree/promotion.rb +1 -0
  114. data/app/models/spree/purchase_order.rb +2 -0
  115. data/app/models/spree/refund.rb +13 -15
  116. data/app/models/spree/return.rb +1 -0
  117. data/app/models/spree/saved_report.rb +1 -0
  118. data/app/models/spree/search_provider/base.rb +4 -0
  119. data/app/models/spree/seller.rb +8 -1
  120. data/app/models/spree/seller_payout.rb +1 -0
  121. data/app/models/spree/seller_requirement.rb +1 -0
  122. data/app/models/spree/seller_requirement_submission.rb +1 -0
  123. data/app/models/spree/seller_transfer.rb +1 -0
  124. data/app/models/spree/shipping_label.rb +1 -0
  125. data/app/models/spree/stock/estimator.rb +1 -1
  126. data/app/models/spree/stock_level.rb +2 -0
  127. data/app/models/spree/stock_location.rb +1 -0
  128. data/app/models/spree/stock_transfer.rb +2 -0
  129. data/app/models/spree/store.rb +37 -12
  130. data/app/models/spree/store_credit.rb +5 -4
  131. data/app/models/spree/supplier.rb +1 -0
  132. data/app/models/spree/tax_category.rb +1 -0
  133. data/app/models/spree/tax_exemption_certificate.rb +1 -0
  134. data/app/models/spree/tax_provider/internal.rb +12 -9
  135. data/app/models/spree/tax_rate.rb +29 -6
  136. data/app/models/spree/taxon.rb +1 -1
  137. data/app/models/spree/update_status.rb +25 -0
  138. data/app/models/spree/variant.rb +6 -9
  139. data/app/models/spree/webhook_delivery.rb +1 -0
  140. data/app/models/spree/webhook_endpoint.rb +36 -0
  141. data/app/presenters/spree/csv/order_line_item_presenter.rb +10 -6
  142. data/app/presenters/spree/csv/price_list_price_presenter.rb +3 -8
  143. data/app/presenters/spree/csv/product_variant_presenter.rb +17 -8
  144. data/app/presenters/spree/csv/purchase_order_item_presenter.rb +1 -3
  145. data/app/services/spree/carts/update.rb +3 -3
  146. data/app/services/spree/imports/row_processors/product_variant.rb +12 -0
  147. data/app/services/spree/locales.rb +39 -5
  148. data/app/services/spree/orders/create.rb +3 -3
  149. data/app/services/spree/orders/discounts/create.rb +8 -8
  150. data/app/services/spree/prices/bulk_upsert.rb +32 -25
  151. data/app/services/spree/seeds/stores.rb +1 -1
  152. data/app/services/spree/stores/provision_defaults.rb +13 -16
  153. data/app/services/spree/update_check.rb +160 -0
  154. data/app/services/spree/uploads/assign_stores.rb +92 -0
  155. data/app/validators/spree/attachment_content_type_validator.rb +19 -2
  156. data/app/validators/spree/locale_codes_validator.rb +22 -0
  157. data/app/workflows/spree/fulfillments/create.rb +2 -2
  158. data/app/workflows/spree/payments/capture.rb +20 -5
  159. data/app/workflows/spree/payments/process.rb +3 -1
  160. data/app/workflows/spree/price_lists/update.rb +4 -4
  161. data/app/workflows/spree/purchase_orders/create.rb +3 -1
  162. data/app/workflows/spree/shipping_labels/record.rb +2 -2
  163. data/app/workflows/spree/stock_transfers/create.rb +3 -1
  164. data/config/currency_exponents.json +180 -0
  165. data/config/locales/content_locales.rb +9 -0
  166. data/config/locales/en.yml +10 -2
  167. data/db/migrate/20261009000001_change_money_columns_to_decimal_19_4.rb +116 -0
  168. data/db/migrate/20261010000001_add_store_id_to_active_storage_blobs.rb +12 -0
  169. data/lib/generators/spree/dummy/templates/rails/test.rb +21 -2
  170. data/lib/spree/core/configuration.rb +18 -0
  171. data/lib/spree/core/engine.rb +26 -0
  172. data/lib/spree/core/preferences/invalid_preferences.rb +18 -0
  173. data/lib/spree/core/preferences/json_conversion.rb +5 -1
  174. data/lib/spree/core/preferences/masking.rb +51 -1
  175. data/lib/spree/core/preferences/preferable.rb +212 -26
  176. data/lib/spree/core/preferences/preferable_class_methods.rb +121 -4
  177. data/lib/spree/core/ransack_configuration.rb +13 -2
  178. data/lib/spree/core/version.rb +1 -1
  179. data/lib/spree/core.rb +6 -3
  180. data/lib/spree/iso_data.rb +7 -0
  181. data/lib/spree/localized_number.rb +6 -0
  182. data/lib/spree/migrations.rb +4 -1
  183. data/lib/spree/money.rb +196 -15
  184. data/lib/spree/reporting/adapters/base.rb +7 -6
  185. data/lib/spree/reporting/registry.rb +13 -0
  186. data/lib/spree/testing_support/factories/product_factory.rb +9 -0
  187. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +22 -1
  188. data/lib/spree/uploads.rb +101 -0
  189. data/lib/tasks/markets.rake +2 -1
  190. data/lib/tasks/migrate_users_to_customers.rake +23 -18
  191. data/lib/tasks/money.rake +46 -0
  192. data/lib/tasks/seller_sample_data.rake +2 -2
  193. data/lib/tasks/store_settings.rake +13 -6
  194. data/lib/tasks/typed_adjustments_migration.rake +1 -1
  195. data/lib/tasks/uploads.rake +19 -0
  196. metadata +35 -6
  197. data/app/jobs/spree/products/touch_categories_job.rb +0 -15
  198. data/app/jobs/spree/products/touch_taxons_job.rb +0 -13
@@ -18,9 +18,16 @@ require 'spree/core/preferences/preferable_class_methods'
18
18
  module Spree::Preferences::Preferable
19
19
  extend ActiveSupport::Concern
20
20
 
21
+ BOOLEAN_TYPE = ActiveModel::Type::Boolean.new
22
+ INTEGER_TYPE = ActiveModel::Type::Integer.new
23
+ STRING_TYPE = ActiveModel::Type::String.new
24
+
21
25
  included do
22
26
  serialize :preferences, coder: Spree::Metadata::HashSerializer
23
27
  extend Spree::Preferences::PreferableClassMethods
28
+
29
+ # The preferences read and written under their plain names (see `exposes_preferences`).
30
+ class_attribute :exposed_preference_names, instance_accessor: false, default: Set.new.freeze
24
31
  end
25
32
 
26
33
  # JSON keeps decimals and times as strings; the declared type turns them
@@ -34,7 +41,7 @@ module Spree::Preferences::Preferable
34
41
  return value unless value.is_a?(String)
35
42
 
36
43
  case type
37
- when :decimal then BigDecimal(value, exception: false) || value
44
+ when :decimal, :money then BigDecimal(value, exception: false) || value
38
45
  when :datetime then Time.zone.iso8601(value)
39
46
  else value
40
47
  end
@@ -42,6 +49,17 @@ module Spree::Preferences::Preferable
42
49
  value
43
50
  end
44
51
 
52
+ # The record class an `of: :id` preference points at. Declared as a class
53
+ # name or a block, so a declaration never loads another model, nor fixes a
54
+ # configurable class like `Spree.customer_class`, at class load.
55
+ #
56
+ # @param definition [Hash] a preference definition
57
+ # @return [Class]
58
+ def self.preference_model(definition)
59
+ model = definition[:model]
60
+ model.respond_to?(:call) ? model.call : model.to_s.constantize
61
+ end
62
+
45
63
  def get_preference(name)
46
64
  has_preference! name
47
65
  public_send(:"preferred_#{name}")
@@ -81,6 +99,24 @@ module Spree::Preferences::Preferable
81
99
  choices.respond_to?(:call) ? choices.call : choices
82
100
  end
83
101
 
102
+ # The currency a `:money` preference is in: its declared `currency:` source
103
+ # (another preference, or a method of the record), else the record's own
104
+ # `currency` preference. Values about to be assigned win over stored ones,
105
+ # so a request changing both the amount and its currency is read as one.
106
+ #
107
+ # @param name [Symbol, String]
108
+ # @param pending [Hash{String => Object}] values not yet assigned
109
+ # @return [String, nil] nil for an amount in any currency
110
+ def money_preference_currency(name, pending = {})
111
+ declared = preference_definition(name)[:currency]
112
+ source = declared || :currency
113
+ # A currency sent in the same payload wins, even a blank one clearing it.
114
+ return pending[source.to_s].presence if pending.key?(source.to_s)
115
+ return get_preference(source).presence if has_preference?(source)
116
+
117
+ public_send(source).presence if declared && respond_to?(source)
118
+ end
119
+
84
120
  def has_preference!(name)
85
121
  raise NoMethodError, "#{name} preference not defined" unless has_preference? name
86
122
  end
@@ -142,6 +178,53 @@ module Spree::Preferences::Preferable
142
178
  clear_attribute_change(:preferences) if persisted? && !already_changed
143
179
  end
144
180
 
181
+ # Writes a `preferences` payload an API client sent. The whole payload is
182
+ # checked against the type's schema first, so nothing is written when any
183
+ # value is wrong. Then each value goes through its typed writer. A secret
184
+ # sent back masked, as it was read, keeps the stored one; `null` clears it.
185
+ #
186
+ # @param values [Hash, ActionController::Parameters]
187
+ # @param pointer [String] where the preferences object sits in the request,
188
+ # e.g. `/rules/1/preferences`, so a failure names its place
189
+ # @raise [Spree::Preferences::InvalidPreferences] naming every value that does not match
190
+ # @return [void]
191
+ def assign_preferences(values, pointer: '/preferences')
192
+ values = values.respond_to?(:to_unsafe_h) ? values.to_unsafe_h : values.to_h
193
+ values = values.deep_stringify_keys
194
+ failures = self.class.preference_failures(values)
195
+ failures += money_precision_failures(values) if failures.empty?
196
+ raise Spree::Preferences::InvalidPreferences.new(failures, prefix: pointer) if failures.any?
197
+
198
+ values.each do |key, value|
199
+ next if secret_preference?(key) && Spree::Preferences::Masking.masked?(value)
200
+
201
+ begin
202
+ set_preference(key, value)
203
+ rescue ActiveRecord::RecordNotFound => e
204
+ raise Spree::Preferences::InvalidPreferences.new([{ pointer: "/#{key}", message: e.message }], prefix: pointer)
205
+ end
206
+ end
207
+ end
208
+
209
+ # A stored value in the form the schema describes. Rows written before the
210
+ # declarations were typed can hold a number where an exact decimal string
211
+ # is declared (an `amounts` hash, a decimal default from YAML), or a string
212
+ # where a number or boolean is; read back as they are, a client sending
213
+ # them unchanged would be refused.
214
+ #
215
+ # @param value [Object] the stored value
216
+ # @param definition [Hash] its preference definition
217
+ # @return [Object]
218
+ def wire_preference_value(value, definition)
219
+ return BigDecimal(value.to_s).as_json if %i[decimal money].include?(definition[:type]) && value.is_a?(Numeric)
220
+ return nil if value == '' && definition[:nullable] && %i[integer boolean].include?(definition[:type])
221
+ return cast_preference_item(value, :integer) if definition[:type] == :integer && value.is_a?(String)
222
+ return convert_preference_value(value, :boolean, nullable: definition[:nullable]) if definition[:type] == :boolean && value.is_a?(String)
223
+ return value if value.nil? || (definition[:type] == :array && !value.is_a?(Array))
224
+
225
+ cast_preference_contents(value, definition)
226
+ end
227
+
145
228
  # Names of the preferences the last save changed, secrets included.
146
229
  #
147
230
  # @return [Array<Symbol>]
@@ -151,6 +234,24 @@ module Spree::Preferences::Preferable
151
234
 
152
235
  private
153
236
 
237
+ # A `:money` value with more decimals than its currency has is refused, as
238
+ # every other amount on the API is. The schema has already checked the
239
+ # format; it cannot know the currency.
240
+ def money_precision_failures(values)
241
+ values.filter_map do |key, value|
242
+ next unless value.is_a?(String) && has_preference?(key) && preference_type(key) == :money
243
+
244
+ currency = money_preference_currency(key, values)
245
+ amount = BigDecimal(value, exception: false)
246
+ next if currency.blank? || amount.nil?
247
+
248
+ places = Spree::Money::Rounding.precision(currency)
249
+ next if amount.round(places) == amount
250
+
251
+ { pointer: "/#{key}", message: I18n.t('spree.errors.messages.money_preference_too_many_decimals', count: places, currency: currency) }
252
+ end
253
+ end
254
+
154
255
  def preference_definition(name)
155
256
  has_preference! name
156
257
  self.class.preference_definitions[name.to_sym]
@@ -162,7 +263,7 @@ module Spree::Preferences::Preferable
162
263
  def stored_default(name)
163
264
  default = preference_default(name)
164
265
  type = preference_type(name)
165
- return default if default.nil? || %i[decimal datetime].exclude?(type)
266
+ return default if default.nil? || %i[decimal money datetime].exclude?(type)
166
267
 
167
268
  convert_preference_value(default, type, nullable: true).as_json
168
269
  end
@@ -182,16 +283,95 @@ module Spree::Preferences::Preferable
182
283
  # `(value, owner = nil)` shapes.
183
284
  value = parse_on_set.arity.abs > 1 ? parse_on_set.call(value, self) : parse_on_set.call(value)
184
285
  end
286
+ # Spree 6.0 bridge, removed with incomplete declarations in 6.1: an id list
287
+ # an extension declared without `of: :id` still has its prefixed ids
288
+ # decoded, as the API did for every `*_ids` key before.
289
+ if definition[:type] == :array && definition[:of].nil? && name.to_s.end_with?('_ids') && value.is_a?(Array)
290
+ value = value.map { |id| (Spree::PrefixedId.decode_prefixed_id(id) if Spree::PrefixedId.prefixed_id?(id)) || id }
291
+ end
185
292
  value = convert_preference_value(value, definition[:type], nullable: definition[:nullable])
293
+ value = cast_preference_contents(value, definition)
186
294
  # Decimals and times are kept in the string form JSON stores, so a record
187
295
  # read back from the database and one just written compare equal and
188
296
  # setting the same value is not a change. The reader restores them.
189
- value = value.as_json if %i[decimal datetime].include?(definition[:type])
297
+ value = value.as_json if %i[decimal money datetime].include?(definition[:type])
190
298
 
191
299
  Spree::Deprecation.warn("`#{name}` is deprecated. #{definition[:deprecated]}") if definition[:deprecated]
192
300
  value
193
301
  end
194
302
 
303
+ # Casts what a typed `:array` or `:hash` holds to its declared item type, in
304
+ # the form JSON stores. A value that does not cast is left as it came, so
305
+ # validation can name it rather than a silent zero hiding it.
306
+ def cast_preference_contents(value, definition)
307
+ case definition[:type]
308
+ when :array
309
+ case definition[:of]
310
+ when nil then value
311
+ when :id then decode_preference_ids(value, definition)
312
+ when :object then value.map { |item| cast_preference_object(item, definition[:properties]) }
313
+ else split_preference_list(value).map { |item| cast_preference_item(item, definition[:of]) }
314
+ end
315
+ when :hash
316
+ return value unless definition[:values] && value.is_a?(Hash)
317
+
318
+ value.to_h do |key, item|
319
+ key = definition[:keys] == :currency ? key.to_s.upcase : key.to_s
320
+ [key, cast_preference_item(item, definition[:values])]
321
+ end
322
+ else
323
+ value
324
+ end
325
+ end
326
+
327
+ # A comma-separated string is a list too: what a plain text field sends.
328
+ def split_preference_list(values)
329
+ values.flat_map { |item| item.is_a?(String) ? item.split(',') : [item] }
330
+ .map { |item| item.is_a?(String) ? item.strip : item }
331
+ .reject { |item| item.respond_to?(:empty?) && item.empty? }
332
+ end
333
+
334
+ def cast_preference_item(value, item_type)
335
+ case item_type
336
+ when :string then value.to_s
337
+ when :integer then Integer(value.to_s, 10, exception: false) || value
338
+ when :decimal, :money then BigDecimal(value.to_s, exception: false)&.as_json || value
339
+ when :boolean then BOOLEAN_TYPE.cast(value)
340
+ else value
341
+ end
342
+ end
343
+
344
+ def cast_preference_object(item, properties)
345
+ return item unless item.respond_to?(:to_h) && !item.is_a?(Array)
346
+
347
+ item.to_h.stringify_keys.slice(*properties.keys.map(&:to_s)).to_h do |key, value|
348
+ [key, cast_preference_item(value, properties[key.to_sym])]
349
+ end
350
+ end
351
+
352
+ # Turns a list of record ids into the raw primary keys the preference
353
+ # stores. A prefixed id must carry the declared model's prefix — a
354
+ # channel's id never decodes into a market id — and every id must belong to
355
+ # the record's scope, so a rule cannot point at another store's records.
356
+ #
357
+ # @raise [ActiveRecord::RecordNotFound] naming the ids that were not found
358
+ def decode_preference_ids(values, definition)
359
+ model = Spree::Preferences::Preferable.preference_model(definition)
360
+ ids = split_preference_list(values).map(&:to_s).map do |id|
361
+ Spree::PrefixedId.prefixed_id?(id) ? (model.decode_prefixed_id(id)&.to_s || id) : id
362
+ end
363
+ return ids if ids.empty?
364
+
365
+ relation = definition[:scope] ? definition[:scope].call(self) : model
366
+ found = relation.where(id: ids.reject { |id| Spree::PrefixedId.prefixed_id?(id) }).pluck(:id).map(&:to_s).to_set
367
+ missing = ids.reject { |id| found.include?(id) }
368
+ if missing.any?
369
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{model.name} with id=#{missing.join(',')}", model.name)
370
+ end
371
+
372
+ ids
373
+ end
374
+
195
375
  # Assigns a new hash rather than calling the store accessor's writer, which
196
376
  # skips writing nil to a missing key — leaving the default in force when nil
197
377
  # was meant. Rails still sees no change when the value is the one stored.
@@ -206,40 +386,46 @@ module Spree::Preferences::Preferable
206
386
  end
207
387
  end
208
388
 
389
+ # Whitespace alone is no number or boolean, so for those it is blank too; a
390
+ # string keeps it.
391
+ def blank_preference_value?(value, type = nil)
392
+ return true if value.nil? || (value.respond_to?(:empty?) && value.empty?)
393
+
394
+ %i[integer boolean].include?(type) && value.is_a?(String) && value.strip.empty?
395
+ end
396
+
209
397
  def convert_preference_value(value, type, nullable: false)
210
398
  case type
399
+ # Strings, integers and booleans cast the way Rails casts attributes (and
400
+ # `has_json` its keys). Our own rule on top: a blank value stays nil for a
401
+ # nullable preference, so it can fall back to another, and becomes the
402
+ # type's empty value otherwise.
211
403
  when :string, :text
212
- # A nullable string keeps "unset" (nil / empty string) as nil so it can
213
- # fall back to another value, instead of collapsing it to "".
214
- if nullable && (value.nil? || (value.respond_to?(:empty?) && value.empty?))
215
- nil
404
+ if blank_preference_value?(value)
405
+ nullable ? nil : ''
216
406
  else
217
- value.to_s
407
+ STRING_TYPE.cast(value)
218
408
  end
219
409
  when :password
220
410
  value.to_s
221
- when :decimal
222
- decimal_value = value.presence
411
+ when :decimal, :money
412
+ # A number or canonical decimal text only: "1,599.99" raises rather
413
+ # than being read as 1.
414
+ decimal_value = value.is_a?(String) ? value.presence : value
223
415
  decimal_value ||= 0 unless nullable
224
- decimal_value.present? ? decimal_value.to_s.to_d : decimal_value
416
+ decimal_value.nil? ? nil : Spree::Money::Rounding.parse_decimal(decimal_value)
225
417
  when :integer
226
- int_value = value.presence
227
- int_value ||= 0 unless nullable
228
- int_value.present? ? int_value.to_i : int_value
418
+ if blank_preference_value?(value, :integer)
419
+ nullable ? nil : 0
420
+ else
421
+ INTEGER_TYPE.cast(value) || 0
422
+ end
229
423
  when :boolean
230
- # A nullable boolean keeps "unset" (nil / empty string) as nil so it can
231
- # fall back to another value, instead of collapsing it to false. An
232
- # explicit false is preserved (it is neither nil nor empty).
233
- if nullable && (value.nil? || (value.respond_to?(:empty?) && value.empty?))
234
- nil
235
- elsif value.is_a?(FalseClass) ||
236
- value.nil? ||
237
- value == 0 ||
238
- value&.to_s =~ /^(f|false|0)$/i ||
239
- (value.respond_to?(:empty?) && value.empty?)
240
- false
424
+ # An explicit false is kept: it is neither nil nor empty.
425
+ if blank_preference_value?(value, :boolean)
426
+ nullable ? nil : false
241
427
  else
242
- true
428
+ BOOLEAN_TYPE.cast(value)
243
429
  end
244
430
  when :array
245
431
  value.is_a?(Array) ? value : Array.wrap(value)
@@ -37,12 +37,42 @@ module Spree::Preferences
37
37
  preference_declarations_cache[:secrets] ||= declared_preference_types.filter_map { |name, type| name if type == :password }.freeze
38
38
  end
39
39
 
40
+ # The value types a list item, a hash value or an object property may
41
+ # declare — what {Spree::PreferenceSchema::JsonSchema} compiles to JSON
42
+ # Schema, along with its `FORMATS`.
43
+ ITEM_TYPES = %i[string integer decimal money boolean id object].freeze
44
+
40
45
  # Declares a typed preference with a `preferred_<name>` reader and writer
41
46
  # and a `prefers_<name>?` query. On a model, the value lives in the
42
47
  # `preferences` column (`secret_preferences` for a `:password` one) as a
43
48
  # Rails store accessor, which brings the change methods with it:
44
49
  # `preferred_<name>_changed?`, `_change`, `_was`,
45
50
  # `saved_change_to_preferred_<name>?` and `preferred_<name>_before_last_save`.
51
+ #
52
+ # The declaration is the preference's contract on the wire, so it states
53
+ # the full type:
54
+ #
55
+ # preference :amount_min, :money
56
+ # preference :channel_ids, :array, of: :id, model: 'Spree::Channel', scope: ->(rule) { rule.store.channels }
57
+ # preference :country_codes, :array, of: :string, format: :iso_country
58
+ # preference :match_policy, :string, choices: %w[any all none]
59
+ # preference :amounts, :hash, keys: :currency, values: :money
60
+ # preference :tiers, :array, of: :object, properties: { threshold: :money, value: :decimal }
61
+ #
62
+ # @param name [Symbol]
63
+ # @param type [Symbol] `:string`, `:text`, `:boolean`, `:integer`, `:decimal`, `:money`,
64
+ # `:array`, `:hash`, `:password`, `:date` or `:datetime`
65
+ # @option options [Symbol] :of the type of each item of an `:array`
66
+ # @option options [Symbol] :keys the type of a `:hash`'s keys (`:string` or `:currency`)
67
+ # @option options [Symbol] :values the type of a `:hash`'s values
68
+ # @option options [Hash{Symbol => Symbol}] :properties the fields of each `of: :object` item
69
+ # @option options [Symbol] :format a string's domain format (see {Spree::PreferenceSchema::JsonSchema::FORMATS})
70
+ # @option options [String, Proc] :model the record class an `of: :id` list points at
71
+ # @option options [Proc] :scope `->(owner) { relation }` the ids must be found in
72
+ # @option options [Array, Proc] :choices the fixed set a value must come from
73
+ # @option options [Symbol] :currency where a `:money` preference's currency comes
74
+ # from: another preference or a method of the record. Defaults to the
75
+ # record's `currency` preference; with neither, the amount is in any currency.
46
76
  def preference(name, type, *args)
47
77
  name = name.to_sym
48
78
  secret = type == :password
@@ -52,20 +82,39 @@ module Spree::Preferences
52
82
  end
53
83
 
54
84
  options = args.extract_options!
55
- options.assert_valid_keys(:default, :deprecated, :in, :internal, :nullable, :parse_on_set)
85
+ options.assert_valid_keys(:default, :deprecated, :in, :choices, :internal, :nullable, :parse_on_set,
86
+ :of, :keys, :values, :properties, :format, :model, :scope, :currency)
87
+ if options.key?(:in)
88
+ Spree::Deprecation.warn("`preference :#{name}, in:` is deprecated. Use `choices:` instead.")
89
+ options[:choices] ||= options.delete(:in)
90
+ end
91
+ check_preference_declaration(name, type, options)
92
+
56
93
  default = options[:default]
57
- default = -> { options[:default] } unless default.is_a?(Proc)
94
+ dynamic_default = default.is_a?(Proc)
95
+ default = -> { options[:default] } unless dynamic_default
58
96
  own_preference_definitions[name] = {
59
97
  type: type,
60
98
  default: default,
99
+ # Computed when read (the default store's currency, say), so it
100
+ # differs between stores and is not part of the preference's schema.
101
+ dynamic_default: dynamic_default,
61
102
  deprecated: options[:deprecated],
62
103
  # Whether the system writes the value rather than the operator — a
63
104
  # value a provider hands back, kept out of every admin form.
64
105
  internal: options[:internal],
65
106
  # The fixed set a value must come from; turns a text box into a picker.
66
- choices: options[:in],
107
+ choices: options[:choices],
67
108
  nullable: options[:nullable],
68
- parse_on_set: options[:parse_on_set]
109
+ parse_on_set: options[:parse_on_set],
110
+ of: options[:of],
111
+ keys: options[:keys],
112
+ values: options[:values],
113
+ properties: options[:properties],
114
+ format: options[:format],
115
+ model: options[:model],
116
+ scope: options[:scope],
117
+ currency: options[:currency]
69
118
  }
70
119
  PreferableClassMethods.declarations += 1
71
120
 
@@ -86,8 +135,76 @@ module Spree::Preferences
86
135
  end
87
136
  end
88
137
 
138
+ # Gives each named preference a plain reader, writer and predicate, so the
139
+ # API reads and writes `guest_checkout` rather than the DSL's
140
+ # `preferred_guest_checkout` — the methods Rails' `has_delegated_json`
141
+ # defines, so callers would not change if the storage ever moved to it.
142
+ # Grants no write access: each API controller still lists what it permits.
143
+ #
144
+ # exposes_preferences :guest_checkout, :timezone
145
+ #
146
+ # @param names [Array<Symbol>]
147
+ # @return [void]
148
+ def exposes_preferences(*names)
149
+ names.each do |name|
150
+ name = name.to_sym
151
+ raise ArgumentError, "#{self.name} has no preference `#{name}` to expose" unless preference_definitions.key?(name)
152
+ raise ArgumentError, "#{self.name} cannot expose the preference `#{name}`: a method of that name exists" if method_defined?(name)
153
+
154
+ define_method(name) { public_send(:"preferred_#{name}") }
155
+ define_method(:"#{name}=") { |value| public_send(:"preferred_#{name}=", value) }
156
+ # As `has_json` defines one; a predicate the model already has (one
157
+ # that falls back to another setting, say) is kept.
158
+ define_method(:"#{name}?") { public_send(:"preferred_#{name}").present? } unless method_defined?(:"#{name}?")
159
+ end
160
+ self.exposed_preference_names = (exposed_preference_names | names.map(&:to_sym)).freeze
161
+ end
162
+
163
+ # The plain name an exposed preference goes by, for a key still using the
164
+ # DSL's `preferred_` prefix — an error key or a 5.x request parameter.
165
+ #
166
+ # @param key [Symbol, String] e.g. `preferred_guest_checkout`
167
+ # @return [Symbol, nil] e.g. `:guest_checkout`, or nil when the key names no exposed preference
168
+ def exposed_preference_name(key)
169
+ name = key.to_s.delete_prefix('preferred_')
170
+ name.to_sym if name != key.to_s && exposed_preference_names.include?(name.to_sym)
171
+ end
172
+
89
173
  private
90
174
 
175
+ # A declaration is the preference's contract on the wire. One Spree ships
176
+ # is always complete (a spec enforces it); an incomplete one from an
177
+ # extension still works in 6.0, with a schema that accepts any value.
178
+ def check_preference_declaration(name, type, options)
179
+ if options[:of] && type != :array
180
+ raise ArgumentError, "`of:` applies to an :array preference, not `#{name}` (#{type})"
181
+ end
182
+ if (options[:keys] || options[:values]) && type != :hash
183
+ raise ArgumentError, "`keys:` and `values:` apply to a :hash preference, not `#{name}` (#{type})"
184
+ end
185
+ [options[:of], options[:values], *options[:properties]&.values].compact.each do |item_type|
186
+ raise ArgumentError, "Unknown item type `#{item_type}` on preference `#{name}`" unless ITEM_TYPES.include?(item_type)
187
+ end
188
+ if options[:format] && !Spree::PreferenceSchema::JsonSchema::FORMATS.key?(options[:format])
189
+ raise ArgumentError, "Unknown format `#{options[:format]}` on preference `#{name}`"
190
+ end
191
+ raise ArgumentError, "`of: :id` needs `model:` on preference `#{name}`" if options[:of] == :id && options[:model].blank?
192
+ raise ArgumentError, "`currency:` applies to a :money preference, not `#{name}` (#{type})" if options[:currency] && type != :money
193
+ raise ArgumentError, "`of: :object` needs `properties:` on preference `#{name}`" if options[:of] == :object && options[:properties].blank?
194
+
195
+ missing = case type
196
+ when :any then 'a type other than :any'
197
+ when :array then '`of:`' unless options[:of]
198
+ when :hash then '`keys:` and `values:`' unless options[:keys] && options[:values]
199
+ end
200
+ return unless missing
201
+
202
+ Spree::Deprecation.warn(
203
+ "#{self.name} preference `#{name}` (#{type}) needs #{missing}; until it has one, its schema accepts any value. " \
204
+ 'Spree 6.1 will raise for an incomplete declaration.'
205
+ )
206
+ end
207
+
91
208
  def own_preference_definitions
92
209
  @own_preference_definitions ||= {}
93
210
  end
@@ -6,7 +6,7 @@ module Spree
6
6
  #
7
7
  # @example Adding custom searchable fields
8
8
  # Spree.ransack.add_attribute(Spree::Product, :seller_id)
9
- # Spree.ransack.add_scope(Spree::Product, :by_seller)
9
+ # Spree.ransack.add_scope(Spree::Product, :by_seller, type: 'id')
10
10
  # Spree.ransack.add_association(Spree::Product, :seller)
11
11
  #
12
12
  class RansackConfiguration
@@ -14,6 +14,7 @@ module Spree
14
14
  @custom_attributes = Hash.new { |h, k| h[k] = [] }
15
15
  @custom_associations = Hash.new { |h, k| h[k] = [] }
16
16
  @custom_scopes = Hash.new { |h, k| h[k] = [] }
17
+ @custom_scope_types = Hash.new { |h, k| h[k] = {} }
17
18
  end
18
19
 
19
20
  # Add a custom ransackable attribute to a model.
@@ -38,8 +39,11 @@ module Spree
38
39
  #
39
40
  # @param model [Class] the model class to configure (e.g., Spree::Product)
40
41
  # @param scope [String, Symbol] the scope to add
42
+ # @param type [String, Array<String>, Hash, nil] the arguments it takes, as
43
+ # in `ransackable_scope_types` — `'boolean'` for a scope taking none
41
44
  # @return [Array<String>] the updated list of custom scopes
42
- def add_scope(model, scope)
45
+ def add_scope(model, scope, type: nil)
46
+ @custom_scope_types[model.name.to_sym][scope.to_s] = type unless type.nil?
43
47
  @custom_scopes[model.name.to_sym] |= [scope.to_s]
44
48
  end
45
49
 
@@ -67,6 +71,12 @@ module Spree
67
71
  @custom_scopes[model.name.to_sym]
68
72
  end
69
73
 
74
+ # @param model [Class] the model class to query
75
+ # @return [Hash{String => Object}] argument types declared with {#add_scope}
76
+ def custom_scope_types_for(model)
77
+ @custom_scope_types[model.name.to_sym]
78
+ end
79
+
70
80
  # Reset all custom configurations. Useful for testing.
71
81
  #
72
82
  # @return [void]
@@ -74,6 +84,7 @@ module Spree
74
84
  @custom_attributes.clear
75
85
  @custom_associations.clear
76
86
  @custom_scopes.clear
87
+ @custom_scope_types.clear
77
88
  end
78
89
  end
79
90
  end
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.0.rc1'.freeze
2
+ VERSION = '6.0.0.rc2'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -17,6 +17,7 @@ require 'awesome_nested_set'
17
17
  require 'cancan'
18
18
  require 'countries/global'
19
19
  require 'friendly_id'
20
+ require 'json_schemer'
20
21
  require 'jwt'
21
22
  require 'liquid'
22
23
  require 'spree/core/emails/escaped_output'
@@ -251,7 +252,7 @@ module Spree
251
252
  # Example:
252
253
  #
253
254
  # Spree.dependencies do |dependency|
254
- # dependency.cart_add_item_service = MyCustomAddToCart
255
+ # dependency.cart_add_item_workflow = MyCustomAddToCart
255
256
  # end
256
257
  #
257
258
  # This method is defined within the core gem on purpose.
@@ -542,10 +543,10 @@ module Spree
542
543
  # Dynamic methods for core dependencies
543
544
  #
544
545
  # @example Getting a dependency (returns resolved class)
545
- # Spree.cart_add_item_service.call(order: order, variant: variant)
546
+ # Spree.cart_add_item_workflow.call(cart: cart, variant: variant)
546
547
  #
547
548
  # @example Setting a dependency
548
- # Spree.cart_add_item_service = MyApp::CartAddItem
549
+ # Spree.cart_add_item_workflow = MyApp::CartAddItem
549
550
  def method_missing(method_name, *args, &block)
550
551
  base_name = method_name.to_s.chomp('=').to_sym
551
552
 
@@ -635,6 +636,7 @@ require 'spree/workflow'
635
636
  require 'spree/reporting'
636
637
  require 'spree/events'
637
638
  require 'spree/store_scope_guard'
639
+ require 'spree/uploads'
638
640
 
639
641
  # Not autoloaded from app/: the registry keeps registered steps in
640
642
  # class-level state, which a reload would discard.
@@ -649,6 +651,7 @@ require 'spree/core/controller_helpers/store'
649
651
 
650
652
  require 'spree/core/preferences/runtime_configuration'
651
653
  require 'spree/core/preferences/masking'
654
+ require 'spree/core/preferences/invalid_preferences'
652
655
  require 'spree/core/preferences/json_conversion'
653
656
 
654
657
  require 'spree/core/permission_configuration'
@@ -88,6 +88,13 @@ module Spree
88
88
  freeze
89
89
  end
90
90
 
91
+ # The alpha-2 code of every orderable country, the closed list the API
92
+ # types publish as `CountryCode`.
93
+ # @return [Array<String>]
94
+ def country_codes
95
+ @country_codes ||= countries.map(&:alpha2).sort.freeze
96
+ end
97
+
91
98
  # @param code [String] alpha-2 or alpha-3 code, any case
92
99
  # @return [ISO3166::Country, nil]
93
100
  def country(code)
@@ -1,7 +1,13 @@
1
1
  module Spree
2
+ # Deprecated: Spree no longer parses numbers by locale. Its APIs take
3
+ # canonical decimal strings ("1234.56"); localizing input is the client's job.
2
4
  class LocalizedNumber
3
5
  # Strips all non-price-like characters from the number, taking into account locale settings.
4
6
  def self.parse(number)
7
+ Spree::Deprecation.warn(
8
+ 'Spree::LocalizedNumber is deprecated and will be removed in Spree 6.1. ' \
9
+ 'Parse a canonical decimal string with BigDecimal, or Spree::Money::Rounding.parse_canonical for API input.'
10
+ )
5
11
  return number unless number.is_a?(String)
6
12
 
7
13
  separator, delimiter = I18n.t([:'number.currency.format.separator', :'number.currency.format.delimiter'])
@@ -29,8 +29,11 @@ module Spree
29
29
 
30
30
  name
31
31
  end.compact
32
+ installed = engine_in_app.map(&:camelize).to_set
32
33
 
33
- missing_migrations = engine_migrations.sort - engine_in_app.sort
34
+ # Compared by class name: installing a migration writes its file name
35
+ # back from the class, so `decimal_19_4` is installed as `decimal194`.
36
+ missing_migrations = engine_migrations.sort.reject { |name| installed.include?(name.camelize) }
34
37
  unless missing_migrations.empty?
35
38
  puts "[#{engine_name.capitalize} WARNING] Missing migrations."
36
39
  missing_migrations.each do |migration|