spree_core 6.0.0.rc1 → 6.0.0.rc3

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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16e068be2852f0ba21dcbb6c34792612f42956a1fb72fbfe6fd8356103df4fea
4
- data.tar.gz: e0c952c2895b904f00ff5b3a3fd99a5b06a9cd56f751ab7972a31a36eb58d8b7
3
+ metadata.gz: 59f0dc211ad34ae5b6985f353cf1a92237d937834cc2135ad93249ee8868aab1
4
+ data.tar.gz: 96e00c6df2bb8f8172c231e672403cd9d25cda61a5deef1413938a327cdce4f5
5
5
  SHA512:
6
- metadata.gz: 16656693e4a5bbfde9ea1c53c86a7fb7605fbb689d92d28c261c62bfb2e8862e12357ff181c2568d0e2d847de0db122a2c62a7d8d936a4cae54082e4a078b49c
7
- data.tar.gz: 602c2b586f08e4332edb18714dbc0e1d97bc4dbe33705ece95259e3f8d2de7bfc0acab52f1150c0154fcab9517c06b47978e872b07f89eaefca1ed7f99d5c58f
6
+ metadata.gz: f5000bd075a0d24d328b56f4fd5f691e9dbcd1a689f1285dcdbb4558e3c8328b3acc9a8ac44e70afff39000daa015722277e3751527c827f2146ec22c34482d4
7
+ data.tar.gz: 3282389785170a88dae97b1f95a51364056955ecb1d8d393e25c7f1c911d94e053681e35f1bf5e80bd4f3533fb8c0cb669e93d1d9ce898b199766970dffbeae7
data/README.md CHANGED
@@ -40,8 +40,8 @@ Services follow a consistent interface pattern and are located in `app/services/
40
40
 
41
41
  ```ruby
42
42
  # Add item to cart
43
- Spree.cart_add_item_service.call(
44
- order: order,
43
+ Spree.cart_add_item_workflow.call(
44
+ cart: cart,
45
45
  variant: variant,
46
46
  quantity: 1
47
47
  )
@@ -76,7 +76,7 @@ Swap out default implementations with custom services:
76
76
 
77
77
  ```ruby
78
78
  # config/initializers/spree.rb
79
- Spree::Dependencies.cart_add_item_service = 'MyCustom::CartAddItem'
79
+ Spree::Dependencies.cart_add_item_workflow = 'MyCustom::CartAddItem'
80
80
  ```
81
81
 
82
82
  ## Configuration
@@ -10,6 +10,10 @@ module Spree
10
10
  # (ActiveJob handler lookup is reverse-declaration-order).
11
11
  discard_on ActiveJob::DeserializationError
12
12
 
13
+ # Bulk writers enqueue this inside their transaction; run before it
14
+ # commits, the job would index the product's previous prices.
15
+ self.enqueue_after_transaction_commit = true
16
+
13
17
  # @param resource_class [String] e.g. 'Spree::Product'
14
18
  # @param resource_id [String] always pass as string for UUID support
15
19
  # @param store_id [String] always pass as string for UUID support
@@ -0,0 +1,49 @@
1
+ module Spree
2
+ module Uploads
3
+ # Deletes uploaded files that were never attached to anything, once they
4
+ # are older than `Spree::Config.unattached_upload_retention_days`.
5
+ #
6
+ # Rails deletes nothing on its own, so an upload abandoned halfway (a
7
+ # closed tab, a failed form) would otherwise stay in storage forever. The
8
+ # retention outlasts an upload reference, which expires after a day, so no
9
+ # file a client can still attach is deleted.
10
+ #
11
+ # Scheduled daily by the host app (`spree/spree-starter`'s recurring.yml
12
+ # ships the entry). Continuable: the cursor is the last file handled, so a
13
+ # deploy mid-run resumes rather than starting over.
14
+ class PurgeUnattachedJob < Spree::BaseJob
15
+ include ActiveJob::Continuable
16
+
17
+ def perform
18
+ step :purge
19
+ end
20
+
21
+ private
22
+
23
+ def purge(step)
24
+ # Only files that belong to a store: a host app may keep blobs of its
25
+ # own outside attachments, and those are not Spree's to delete.
26
+ blobs = ActiveStorage::Blob.unattached.where.not(store_id: nil).
27
+ where(created_at: ...Spree::Config.unattached_upload_retention_days.days.ago)
28
+ blobs = blobs.where(ActiveStorage::Blob.arel_table[:id].gt(step.cursor)) if step.cursor
29
+
30
+ blobs.find_each(order: :asc) do |blob|
31
+ purge_blob(blob)
32
+ step.set!(blob.id)
33
+ end
34
+ end
35
+
36
+ # A file attached between the query and the purge is kept. The check
37
+ # covers databases that do not enforce the attachments foreign key; the
38
+ # rescue covers the moment between the check and the delete on those
39
+ # that do.
40
+ def purge_blob(blob)
41
+ return if blob.attachments.exists?
42
+
43
+ blob.purge
44
+ rescue ActiveRecord::InvalidForeignKey
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
@@ -72,10 +72,11 @@ module Spree
72
72
 
73
73
  # Locale for an email to a staff member or seller: their own dashboard
74
74
  # language, then the store's admin locale, then nil — which lets
75
- # with_store_locale fall back to the store's default. Blank or unavailable
76
- # values fall through.
75
+ # with_store_locale fall back to the store's default. Blank values, and
76
+ # languages Spree ships no translations for, fall through.
77
77
  def staff_locale(user, store)
78
- [user.try(:selected_locale), store&.preferred_admin_locale].find { |locale| I18n.locale_available?(locale) }
78
+ shipped = Spree.available_locales.map(&:to_s)
79
+ [user.try(:selected_locale), store&.preferred_admin_locale].find { |locale| shipped.include?(locale.to_s) }
79
80
  end
80
81
 
81
82
  # Renders the current action's email from its Liquid template and builds
@@ -4,6 +4,7 @@ module Spree
4
4
 
5
5
  include Spree::Actor
6
6
  include Spree::PrefixedId
7
+ include Spree::SignedIdAttachments
7
8
  include Spree::UserRoles
8
9
  include Spree::RansackableAttributes
9
10
 
@@ -66,6 +67,7 @@ module Spree
66
67
 
67
68
  # Attachments
68
69
  has_one_attached :avatar, service: Spree.public_storage_service_name
70
+ signed_id_attachments :avatar
69
71
 
70
72
  validates :avatar, content_type: Rails.application.config.active_storage.web_image_content_types
71
73
 
@@ -26,32 +26,60 @@ module Spree
26
26
  def calculator_type=(calculator_type)
27
27
  return if calculator_type.blank?
28
28
 
29
- registry = self.class.respond_to?(:calculators) ? self.class.calculators : []
30
- klass = registry.find { |k| k.api_type == calculator_type.to_s }
29
+ klass = registered_calculator_class(calculator_type)
31
30
  self.calculator = klass.new if klass && !calculator.instance_of?(klass)
32
31
  end
33
32
 
34
- # API v3 writer for the flat `calculator: { type:, preferences: {} }`
35
- # payload. Routes preferences through `set_preference` so values are
36
- # coerced by the typed `preferred_<name>=` setters — direct
37
- # assignment to the serialized hash would skip coercion.
38
- def assign_calculator_attributes(attrs)
33
+ # API v3 writer for the `calculator: { type:, preferences: {} }`
34
+ # payload. A type the registry does not list is refused like a setting
35
+ # that does not match (`/calculator/type`); preferences are checked
36
+ # against the calculator's schema and written through its typed writers.
37
+ #
38
+ # @param attrs [Hash, ActionController::Parameters, nil]
39
+ # @param pointer [String] where the calculator sits in the request
40
+ # @raise [Spree::Preferences::InvalidPreferences] for a type the registry does not list, or
41
+ # preferences that do not match its schema
42
+ # @return [void]
43
+ def assign_calculator_attributes(attrs, pointer: '/calculator')
39
44
  return if attrs.nil?
40
45
 
41
46
  attrs = attrs.to_h.with_indifferent_access
42
- self.calculator_type = attrs[:type] if attrs[:type].present?
43
-
44
- return if calculator.nil? || attrs[:preferences].blank?
47
+ type = attrs[:type].to_s
48
+ target = calculator
49
+ if type.present? && calculator&.class&.api_type != type
50
+ klass = registered_calculator_class(type)
51
+ unless klass
52
+ raise Spree::Preferences::InvalidPreferences.new(
53
+ [{ pointer: '/type', message: I18n.t('spree.errors.messages.unknown_calculator_type', type: type) }], prefix: pointer
54
+ )
55
+ end
45
56
 
46
- attrs[:preferences].to_h.each do |key, value|
47
- next unless calculator.has_preference?(key.to_sym)
57
+ target = klass.new
58
+ end
48
59
 
49
- calculator.set_preference(key.to_sym, value)
60
+ if attrs[:preferences].present?
61
+ # Preferences sent without a type land on the default calculator
62
+ # rather than on nothing, for a model that has one.
63
+ target ||= default_calculator if respond_to?(:default_calculator, true)
64
+ target&.assign_preferences(attrs[:preferences], pointer: "#{pointer}/preferences")
50
65
  end
66
+
67
+ # Attached only once its settings are accepted: on a saved owner,
68
+ # assigning a has_one replaces the stored calculator immediately.
69
+ self.calculator = target unless target.equal?(calculator)
51
70
  end
52
71
 
53
72
  private
54
73
 
74
+ # The registered calculator for an API shorthand, resolved from the
75
+ # registry entry's own name: nothing user-supplied reaches
76
+ # `constantize`, and in development the registry can hold a class from
77
+ # an earlier reload whose instances fail the association's type check.
78
+ def registered_calculator_class(type)
79
+ registry = self.class.respond_to?(:calculators) ? self.class.calculators : []
80
+ registry.find { |klass| klass.api_type == type.to_s }&.to_s&.constantize
81
+ end
82
+
55
83
  def self.model_name_without_spree_namespace
56
84
  to_s.tableize.tr('/', '_').sub('spree_', '')
57
85
  end
@@ -13,7 +13,7 @@ module Spree
13
13
  extend ActiveSupport::Concern
14
14
 
15
15
  included do
16
- preference :amounts, :hash, default: {}
16
+ preference :amounts, :hash, keys: :currency, values: :money, default: {}
17
17
  end
18
18
 
19
19
  # The amount this calculator quotes for a currency: the per-currency
@@ -9,24 +9,11 @@ module Spree
9
9
  extend ActiveSupport::Concern
10
10
 
11
11
  included do
12
- preference :tiers, :array, default: [], parse_on_set: ->(value) { Tiers.normalize(value) }
12
+ preference :tiers, :array, of: :object, properties: { threshold: :money, value: :decimal }, default: []
13
13
 
14
14
  validate :preferred_tiers_content
15
15
  end
16
16
 
17
- # @param value [Object] tiers as an operator or API client sent them
18
- # @return [Object] an array of string-keyed hashes with numbers as exact
19
- # decimal strings — the form JSON stores, so a fresh value compares
20
- # equal to one read back — or the value unchanged when it is not a list
21
- # of hashes, so validation can name the problem
22
- def self.normalize(value)
23
- return value unless value.is_a?(Array) && value.all? { |tier| tier.respond_to?(:to_h) && !tier.is_a?(Array) }
24
-
25
- value.map do |tier|
26
- tier.to_h.stringify_keys.to_h { |key, number| [key, decimal(number)&.as_json || number] }
27
- end
28
- end
29
-
30
17
  # @return [BigDecimal, nil] nil when the value is not a number
31
18
  def self.decimal(value)
32
19
  BigDecimal(value.to_s, exception: false)
@@ -0,0 +1,61 @@
1
+ module Spree
2
+ # Decimal attributes written as a number or canonical decimal text
3
+ # ("1234.56"). Text in any other shape raises Spree::Money::InvalidFormat,
4
+ # which the API answers as `invalid_money_format`, instead of being read as
5
+ # a wrong number the way Rails' cast reads "1,599.99" as 1 and "$10" as 0.
6
+ #
7
+ # The columns hold four decimals, so a value with more decimals than it may
8
+ # carry is refused on validation rather than stored and left to drift: an
9
+ # amount that changes hands keeps its currency's decimals, a unit price four,
10
+ # a rate its column's scale.
11
+ module CanonicalDecimals
12
+ extend ActiveSupport::Concern
13
+
14
+ private
15
+
16
+ # nil when the record has no currency yet (a payment before its order),
17
+ # which leaves the check to the record's own presence validations.
18
+ def canonical_decimals_currency_places
19
+ currency = try(:currency)
20
+ Spree::Money::Rounding.precision(currency) if currency.present?
21
+ rescue ActiveSupport::DelegationError
22
+ nil
23
+ end
24
+
25
+ class_methods do
26
+ # @param names [Array<Symbol>]
27
+ # @param places [:currency, Integer, nil] decimals allowed: the record's
28
+ # currency's, a fixed count, or nil for no check
29
+ # @param ignore_blank [Boolean] leave the attribute as it was when given a blank
30
+ def canonical_decimals(*names, places: :currency, ignore_blank: false)
31
+ names.each do |name|
32
+ define_method("#{name}=") do |value|
33
+ next if ignore_blank && value.blank?
34
+
35
+ super(Spree::Money::Rounding.parse_decimal(value))
36
+ rescue Spree::Money::InvalidFormat => error
37
+ raise Spree::Money::InvalidFormat.new(error.message, field: name)
38
+ end
39
+ end
40
+
41
+ return if places.nil?
42
+
43
+ validate do
44
+ allowed = places == :currency ? canonical_decimals_currency_places : places
45
+ next if allowed.nil?
46
+
47
+ names.each do |name|
48
+ next unless will_save_change_to_attribute?(name)
49
+
50
+ # Read before the column's scale rounds it away.
51
+ raw = read_attribute_before_type_cast(name)
52
+ value = raw.is_a?(Numeric) ? BigDecimal(raw.to_s) : self[name]
53
+ next if value.nil? || value.round(allowed) == value
54
+
55
+ errors.add(name, :too_many_decimals, count: allowed)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -3,6 +3,7 @@ module Spree
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  include Spree::PrefixedId
6
+ include Spree::SignedIdAttachments
6
7
  include Spree::HasCustomFields
7
8
  include Spree::UserAddress
8
9
  include Spree::UserPaymentSource
@@ -119,6 +120,7 @@ module Spree
119
120
  # Attachments
120
121
  #
121
122
  has_one_attached :avatar, service: Spree.public_storage_service_name
123
+ signed_id_attachments :avatar
122
124
 
123
125
  has_spree_rich_text :internal_note
124
126
 
@@ -188,9 +190,10 @@ module Spree
188
190
 
189
191
  self.whitelisted_ransackable_associations = %w[bill_address ship_address addresses tags spree_roles orders customer_groups]
190
192
  self.whitelisted_ransackable_attributes = %w[id email first_name last_name phone accepts_email_marketing
191
- created_at updated_at last_sign_in_at]
193
+ created_at updated_at]
192
194
  self.whitelisted_ransackable_scopes = %w[search with_min_total_spent with_standing_for_company
193
195
  anonymized]
196
+ self.ransackable_scope_types = { 'with_min_total_spent' => 'decimal', 'with_standing_for_company' => { list: 'id' }, 'anonymized' => 'boolean' }
194
197
 
195
198
  # Two-state scope: see Spree::Base.ransack_flag? for why the cast is
196
199
  # opted out of here and done inside the scope instead.
@@ -222,10 +225,11 @@ module Spree
222
225
  }
223
226
 
224
227
  # Customers with standing for a company: members of the node or of any
225
- # ancestor (see #standing_for?). Accepts a record, an id, or an array.
226
- scope :with_standing_for_company, ->(companies) {
228
+ # ancestor (see #standing_for?). Accepts records, ids, or an array; a
229
+ # splat because Ransack passes each value of a list as its own argument.
230
+ scope :with_standing_for_company, ->(*companies) {
227
231
  scoped = Spree::Current.store&.companies || Spree::Company.none
228
- nodes = Array.wrap(companies).filter_map do |company|
232
+ nodes = companies.flatten.filter_map do |company|
229
233
  company.is_a?(Spree::Company) ? company : scoped.find_by_param(company)
230
234
  end
231
235
  next none if nodes.empty?
@@ -43,24 +43,22 @@ module Spree
43
43
  # unsaved record sorts last, which is what a merchant watching a new row
44
44
  # appear expects.
45
45
  def position_among_siblings
46
- scope = sibling_scope
47
- return 1 if scope.nil?
46
+ parent = owner
47
+ association = sibling_association_name
48
+ return 1 if parent.nil? || !parent.respond_to?(association)
48
49
 
49
- earlier = id.nil? ? scope.count : scope.where(scope.arel_table[:id].lt(id)).count
50
+ earlier = if id.present? && parent.read_association_in_memory?(association)
51
+ parent.public_send(association).to_a.count { |sibling| sibling.id.present? && sibling.id < id }
52
+ else
53
+ scope = parent.public_send(association).unscope(:order)
54
+ id.nil? ? scope.count : scope.where(scope.arel_table[:id].lt(id)).count
55
+ end
50
56
 
51
57
  earlier + 1
52
58
  end
53
59
 
54
- # The siblings sharing this record's parent. Nil when there is no parent
55
- # to group by.
56
- def sibling_scope
57
- parent = owner
58
- return if parent.nil?
59
-
60
- association = self.class.name.demodulize.underscore.pluralize
61
- return unless parent.respond_to?(association)
62
-
63
- parent.public_send(association).unscope(:order)
60
+ def sibling_association_name
61
+ self.class.name.demodulize.underscore.pluralize
64
62
  end
65
63
  end
66
64
  end
@@ -0,0 +1,139 @@
1
+ module Spree
2
+ module PreferenceSchema
3
+ # Compiles a class's preference declarations into the JSON Schema of its
4
+ # `preferences` object — the contract the OpenAPI components, the SDK
5
+ # types, the `/…/types` endpoints and write validation all read, so none
6
+ # of them is written by hand.
7
+ module JsonSchema
8
+ DECIMAL_PATTERN = '^-?\d+(\.\d+)?$'.freeze
9
+
10
+ # Domain formats, with the pattern that makes each one checkable by any
11
+ # validator, not only those that know the format's name.
12
+ FORMATS = {
13
+ currency: { format: 'currency', pattern: '^[A-Z]{3}$' },
14
+ iso_country: { format: 'iso-country', pattern: '^[A-Z]{2}$' },
15
+ timezone: { format: 'timezone' },
16
+ locale: { format: 'locale' },
17
+ url: { format: 'uri' },
18
+ color: { format: 'color', pattern: '^#[0-9A-Fa-f]{6}$' }
19
+ }.freeze
20
+
21
+ # @param klass [Class] a class including {Spree::PreferenceSchema}
22
+ # @return [Hash] a JSON Schema object (string keys)
23
+ def self.for(klass)
24
+ fields = klass.preference_schema
25
+ definitions = klass.preference_definitions
26
+ properties = fields.to_h do |field|
27
+ [field[:key].to_s, property_schema(definitions.fetch(field[:key]), default: field[:default])]
28
+ end
29
+
30
+ { 'type' => 'object', 'properties' => properties, 'additionalProperties' => false }
31
+ end
32
+
33
+ # The published schema plus the deprecated preferences, which a write
34
+ # still accepts until their removal although no read returns them.
35
+ #
36
+ # @param klass [Class]
37
+ # @return [Hash]
38
+ def self.writable(klass)
39
+ deprecated = klass.preference_definitions.filter_map do |name, definition|
40
+ [name.to_s, property_schema(definition)] if definition[:deprecated] && !definition[:internal]
41
+ end
42
+ schema = klass.preference_json_schema
43
+ schema.merge('properties' => schema['properties'].merge(deprecated.to_h))
44
+ end
45
+
46
+ # The schema of one declared preference.
47
+ #
48
+ # @param definition [Hash] an entry of `preference_definitions`
49
+ # @param default [Object] the declared default, already evaluated
50
+ # @return [Hash]
51
+ def self.property_schema(definition, default: declared_default(definition))
52
+ schema = value_schema(definition)
53
+ choices = definition[:choices]
54
+ choices = choices.call if choices.respond_to?(:call)
55
+ schema['enum'] = choices.map { |choice| choice.is_a?(Symbol) ? choice.to_s : choice } if choices.present?
56
+ default = definition[:dynamic_default] ? nil : wire_default(default, definition)
57
+ unset_reads_null = default.nil? && !definition[:dynamic_default]
58
+
59
+ # A value nothing has set reads back as null, so a preference without
60
+ # a default is nullable on the wire whatever its declared type — and so
61
+ # is a secret, whose default is never sent.
62
+ if (definition[:nullable] || unset_reads_null || definition[:type] == :password) && schema['type']
63
+ schema['type'] = [schema['type'], 'null']
64
+ schema['enum'] += [nil] if schema['enum']
65
+ end
66
+ # A secret's default would leak alongside the masked value.
67
+ schema['default'] = default unless default.nil? || definition[:type] == :password
68
+ schema
69
+ end
70
+
71
+ # A default can be a block that reads the database (the default store's
72
+ # currency); one that cannot be read here is left out.
73
+ def self.declared_default(definition)
74
+ definition[:default].call
75
+ rescue StandardError
76
+ nil
77
+ end
78
+
79
+ def self.value_schema(definition)
80
+ case definition[:type]
81
+ when :string then string_schema(definition[:format])
82
+ when :text then { 'type' => 'string', 'x-spree-widget' => 'textarea' }
83
+ when :boolean then { 'type' => 'boolean' }
84
+ when :integer then { 'type' => 'integer' }
85
+ when :decimal, :money then item_schema(definition[:type], definition)
86
+ when :password then { 'type' => 'string', 'x-spree-secret' => true }
87
+ when :date then { 'type' => 'string', 'format' => 'date' }
88
+ when :datetime then { 'type' => 'string', 'format' => 'date-time' }
89
+ when :array
90
+ schema = { 'type' => 'array' }
91
+ schema['items'] = item_schema(definition[:of], definition) if definition[:of]
92
+ schema
93
+ when :hash
94
+ schema = { 'type' => 'object' }
95
+ if definition[:keys] && definition[:values]
96
+ schema['propertyNames'] = definition[:keys] == :currency ? string_schema(:currency) : { 'type' => 'string' }
97
+ schema['additionalProperties'] = item_schema(definition[:values], definition)
98
+ end
99
+ schema
100
+ else
101
+ {}
102
+ end
103
+ end
104
+
105
+ def self.item_schema(item_type, definition)
106
+ case item_type
107
+ when :string then string_schema(definition[:format])
108
+ when :integer then { 'type' => 'integer' }
109
+ when :boolean then { 'type' => 'boolean' }
110
+ when :decimal then { 'type' => 'string', 'pattern' => DECIMAL_PATTERN }
111
+ when :money then { 'type' => 'string', 'format' => 'money', 'pattern' => DECIMAL_PATTERN }
112
+ when :id
113
+ prefix = Spree::Preferences::Preferable.preference_model(definition)._prefix_id_prefix
114
+ { 'type' => 'string', 'format' => 'prefixed-id', 'pattern' => "^#{prefix}_[A-Za-z0-9]+$", 'x-spree-prefix' => prefix }
115
+ when :object
116
+ properties = definition[:properties].to_h { |name, type| [name.to_s, item_schema(type, {})] }
117
+ { 'type' => 'object', 'properties' => properties, 'required' => properties.keys, 'additionalProperties' => false }
118
+ else
119
+ {}
120
+ end
121
+ end
122
+
123
+ def self.string_schema(format)
124
+ { 'type' => 'string' }.merge(FORMATS.fetch(format, {}).transform_keys(&:to_s))
125
+ end
126
+
127
+ # The default in the form the API takes it back: a decimal as its exact
128
+ # string without trailing zeros ("100", "0.23"), as every API decimal is.
129
+ def self.wire_default(default, definition)
130
+ return default if default.nil?
131
+ return Spree::Money::Rounding.format_decimal(default) if %i[decimal money].include?(definition[:type])
132
+
133
+ default.as_json
134
+ end
135
+
136
+ private_class_method :declared_default, :value_schema, :item_schema, :string_schema, :wire_default
137
+ end
138
+ end
139
+ end
@@ -50,12 +50,41 @@ module Spree
50
50
  # runtime. Each entry also caches `key_string` (frozen) so hot-path
51
51
  # serializers don't allocate `pref.to_s` per request.
52
52
  def preference_schema
53
- @preference_schema ||= compute_preference_schema
53
+ return @preference_schema if @preference_schema
54
54
 
55
- # An empty list to callers either way; only the memo tells the two
56
- # apart, so a schema that could not be computed is retried rather than
57
- # settled on.
58
- @preference_schema || []
55
+ fields, complete = compute_preference_schema
56
+ # Settled on only when every default could be read; one computed
57
+ # before the database was up is retried next time.
58
+ @preference_schema = fields if complete
59
+ fields
60
+ end
61
+
62
+ # The JSON Schema of this class's `preferences` object (see
63
+ # {Spree::PreferenceSchema::JsonSchema}). Memoized like
64
+ # {#preference_schema}.
65
+ #
66
+ # @return [Hash]
67
+ def preference_json_schema
68
+ return @preference_json_schema if @preference_json_schema
69
+
70
+ schema = Spree::PreferenceSchema::JsonSchema.for(self)
71
+ @preference_json_schema = schema if @preference_schema
72
+ schema
73
+ end
74
+
75
+ # Checks a `preferences` payload against {#preference_json_schema}, which
76
+ # also accepts deprecated preferences until their removal.
77
+ #
78
+ # @param values [Hash{String => Object}]
79
+ # @return [Array<Hash{Symbol => String}>] `{ pointer:, message: }` per failure, empty when it matches
80
+ def preference_failures(values)
81
+ # The published schema plus deprecated preferences, which a write
82
+ # still accepts until their removal. Memoized like the published one.
83
+ schemer = @preference_schemer || JSONSchemer.schema(Spree::PreferenceSchema::JsonSchema.writable(self))
84
+ @preference_schemer ||= schemer if @preference_schema
85
+ schemer.validate(values).map do |error|
86
+ { pointer: error['data_pointer'], message: error['error'] }
87
+ end
59
88
  end
60
89
 
61
90
  # Wire-safe variant of `preference_schema` with `:password`
@@ -77,6 +106,10 @@ module Spree
77
106
  wire = { key: field[:key], type: field[:type], default: field[:default] }
78
107
  wire[:choices] = field[:choices] if field[:choices].present?
79
108
  wire[:default] = nil if field[:type] == :password
109
+ # A decimal default goes out as the decimal string the API takes back.
110
+ if %i[decimal money].include?(field[:type]) && field[:default].is_a?(Numeric)
111
+ wire[:default] = Spree::Money::Rounding.format_decimal(field[:default])
112
+ end
80
113
  wire.freeze
81
114
  end.freeze
82
115
  end
@@ -93,24 +126,26 @@ module Spree
93
126
  .freeze
94
127
  end
95
128
 
129
+ # @return [Array(Array<Hash>, Boolean)] the fields, and whether their
130
+ # defaults could all be read
96
131
  def compute_preference_schema
97
132
  # Only instantiation is guarded. `new` touches the database to read
98
133
  # the column list, so it fails whenever the schema is asked for before
99
- # a connection exists — at boot, or in a rake task on an empty
100
- # database. Describing the preferences themselves is pure Ruby, so an
101
- # error there is a real bug: left inside the rescue it would return an
102
- # empty schema and quietly strip every field from the admin form.
103
- # Nil rather than an empty list, so `preference_schema`'s `||=` asks
104
- # again next time. Memoizing the failure would leave a class describing
105
- # no preferences for the life of the process because it was first asked
106
- # before the database was up.
134
+ # a connection exists — at boot, in a rake task on an empty database,
135
+ # or while generating the SDK types. An uninitialized instance still
136
+ # describes every preference, since the declarations are pure Ruby;
137
+ # only a default read from the database is missing, so that result is
138
+ # not memoized. An error describing the preferences themselves is a
139
+ # real bug and is left to raise.
140
+ complete = true
107
141
  instance = begin
108
142
  new
109
143
  rescue StandardError
110
- return nil
144
+ complete = false
145
+ allocate
111
146
  end
112
147
 
113
- instance.defined_preferences.filter_map do |pref|
148
+ fields = instance.defined_preferences.filter_map do |pref|
114
149
  next if instance.preference_deprecated(pref)
115
150
  # Written by Spree, not supplied by the operator — a value a
116
151
  # provider hands back after we register something with it. Offering
@@ -126,6 +161,7 @@ module Spree
126
161
  choices: instance.preference_choices(pref)
127
162
  }.compact.freeze
128
163
  end
164
+ [fields, complete]
129
165
  end
130
166
 
131
167
  # Builds a `parse_on_set:` lambda for `preference :foo_ids, :array`
@@ -150,6 +186,9 @@ module Spree
150
186
  # relation builder; defaults to the unscoped `klass`.
151
187
  # @return [Proc] suitable for the `parse_on_set:` preference option.
152
188
  def normalize_id_preference(klass: nil, scope: nil)
189
+ Spree::Deprecation.warn(
190
+ '`normalize_id_preference` is deprecated. Declare the list as `preference :name, :array, of: :id, model:, scope:`.'
191
+ )
153
192
  lambda do |values, owner = nil|
154
193
  raw = Array(values).flat_map { |v| v.to_s.split(',') }.compact_blank.map(&:strip)
155
194
  next raw unless klass
@@ -178,11 +217,10 @@ module Spree
178
217
  registered_subclasses.find { |klass| klass.api_type == shorthand.to_s }
179
218
  end
180
219
 
181
- # Returns a `[{ type:, label:, description:, preference_schema: }]`
182
- # array for every concrete subclass in `subclasses`. Sorted by label
183
- # for stable output. Uses `serialized_preference_schema` so
184
- # `:password` defaults are redacted — `/types` is an unauthenticated
185
- # discovery surface and must never leak gateway-shipped defaults.
220
+ # Returns a `[{ type:, label:, description:, schema: }]` array for every
221
+ # registered subclass, sorted by label for stable output. `schema` is
222
+ # the JSON Schema of the subclass's `preferences`, which never carries a
223
+ # secret's default.
186
224
  #
187
225
  # `label` and `description` are a fallback, not the admin UI's copy:
188
226
  # they resolve in the request's locale, which is the store's rather
@@ -199,7 +237,7 @@ module Spree
199
237
  # Asking for only one left promotion types with a null description
200
238
  # and no fallback for a client without its own translation.
201
239
  description: subclass_description(klass),
202
- preference_schema: klass.respond_to?(:serialized_preference_schema) ? klass.serialized_preference_schema : []
240
+ schema: klass.preference_json_schema
203
241
  }
204
242
  # Only present when true, so families without the concept keep
205
243
  # their wire shape byte-identical. Pickers stop offering a