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
@@ -20,7 +20,8 @@ module Spree
20
20
  .group("#{Variant.table_name}.product_id")
21
21
  .to_sql
22
22
 
23
- price_expr = Arel.sql('COALESCE(price_agg.agg_price, 999999999)')
23
+ # Above any price decimal(19,4) holds, so an unpriced product sorts last.
24
+ price_expr = Arel.sql('COALESCE(price_agg.agg_price, 1000000000000000)')
24
25
 
25
26
  joins("LEFT JOIN (#{price_agg_sql}) AS price_agg ON price_agg.product_id = #{Product.table_name}.id").
26
27
  select("#{Product.table_name}.*").
@@ -109,6 +110,9 @@ module Spree
109
110
  #
110
111
  # Spree::Product.in_taxon(taxon).count(distinct: true)
111
112
  scope :in_taxon, ->(taxon) {
113
+ taxon = Spree::Category.find_by_prefix_id(taxon) if taxon.is_a?(String)
114
+ next none if taxon.nil?
115
+
112
116
  joins(:product_categories).
113
117
  where("#{Spree::ProductCategory.table_name}.category_id" => taxon.cached_self_and_descendants_ids).distinct
114
118
  }
@@ -46,7 +46,7 @@ module Spree
46
46
  #
47
47
  # @return [Boolean]
48
48
  def payment_required?
49
- total.to_f > 0.0
49
+ total.to_d.positive?
50
50
  end
51
51
 
52
52
  # Whether a confirm/review pass is expected before completion —
@@ -17,8 +17,8 @@ module Spree
17
17
  MAX_PO_DOCUMENT_SIZE = 10.megabytes
18
18
 
19
19
  # What a purchase order plausibly arrives as: an export from the buyer's
20
- # procurement system, a scan, or a photo of one. Matches the list the
21
- # seller document requirement accepts — the same kinds of paperwork.
20
+ # procurement system (often a spreadsheet or CSV), a scan, or a photo of
21
+ # one.
22
22
  PO_DOCUMENT_CONTENT_TYPES = %w[
23
23
  application/pdf
24
24
  image/jpeg
@@ -27,6 +27,10 @@ module Spree
27
27
  image/webp
28
28
  application/msword
29
29
  application/vnd.openxmlformats-officedocument.wordprocessingml.document
30
+ application/vnd.ms-excel
31
+ application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
32
+ application/vnd.oasis.opendocument.spreadsheet
33
+ text/csv
30
34
  ].freeze
31
35
 
32
36
  included do
@@ -36,6 +40,7 @@ module Spree
36
40
  # internal cost codes, and is served only through an authenticated
37
41
  # download action.
38
42
  has_one_attached :po_document, service: Spree.private_storage_service_name
43
+ signed_id_attachments :po_document
39
44
 
40
45
  validates :po_document,
41
46
  size: { less_than_or_equal_to: MAX_PO_DOCUMENT_SIZE },
@@ -58,7 +58,7 @@ module Spree
58
58
 
59
59
  # @return [BigDecimal]
60
60
  def total_applied_store_credit
61
- if payments.loaded?
61
+ if read_association_in_memory?(:payments)
62
62
  payments.
63
63
  find_all(&:store_credit?).
64
64
  reject(&:has_invalid_status?).
@@ -68,10 +68,32 @@ module Spree
68
68
  end
69
69
  end
70
70
 
71
+ # Balance still to collect, never negative. Store credit already applied
72
+ # but not yet captured is taken off; captured credit is a completed
73
+ # payment, so the balance has counted it already.
74
+ #
75
+ # @return [BigDecimal]
76
+ def amount_due
77
+ [outstanding_balance - uncaptured_store_credit, 0].max
78
+ end
79
+
80
+ # What store credit paid, apart from gift cards. A gift card is redeemed
81
+ # as store credit, but it is reported on its own as +gift_card_total+, so
82
+ # a summary showing both rows deducts each amount once.
83
+ #
84
+ # @return [BigDecimal]
85
+ def store_credit_total
86
+ total_applied_store_credit - gift_card_total
87
+ end
88
+
71
89
  def using_store_credit?
72
90
  total_applied_store_credit > 0
73
91
  end
74
92
 
93
+ def display_store_credit_total
94
+ Spree::Money.new(-store_credit_total, currency: currency)
95
+ end
96
+
75
97
  def display_total_applicable_store_credit
76
98
  Spree::Money.new(-total_applicable_store_credit, currency: currency)
77
99
  end
@@ -95,6 +117,19 @@ module Spree
95
117
  def display_total_minus_store_credits
96
118
  Spree::Money.new(total_minus_store_credits, currency: currency)
97
119
  end
120
+
121
+ private
122
+
123
+ # @return [BigDecimal]
124
+ def uncaptured_store_credit
125
+ if read_association_in_memory?(:payments)
126
+ payments.
127
+ select { |payment| payment.store_credit? && !payment.has_invalid_status? && !payment.completed? }.
128
+ sum(BigDecimal('0'), &:amount)
129
+ else
130
+ payments.store_credits.valid.where.not(status: 'completed').sum(:amount)
131
+ end
132
+ end
98
133
  end
99
134
  end
100
135
  end
@@ -39,7 +39,7 @@ module Spree
39
39
 
40
40
  line_items.sum(BigDecimal('0')) do |line_item|
41
41
  gifted_quantity = excluded_gift_promo_actions.sum { |action| action.gifted_quantity_of(line_item) }
42
- line_item.price * [gifted_quantity, line_item.quantity].min
42
+ Spree::Money::Rounding.to_currency(line_item.price * [gifted_quantity, line_item.quantity].min, line_item.currency)
43
43
  end
44
44
  end
45
45
 
@@ -75,13 +75,6 @@ module Spree
75
75
  outstanding_balance != 0
76
76
  end
77
77
 
78
- # Balance still to collect after applied store credit, never negative.
79
- #
80
- # @return [BigDecimal]
81
- def amount_due
82
- [outstanding_balance - total_applied_store_credit, 0].max
83
- end
84
-
85
78
  # @return [Boolean]
86
79
  def paid?
87
80
  total.positive? && payment_total >= total
@@ -11,6 +11,12 @@ module Spree::RansackableAttributes
11
11
  class_attribute :whitelisted_ransackable_attributes
12
12
  class_attribute :whitelisted_ransackable_scopes
13
13
 
14
+ # `{ 'price_between' => %w[decimal decimal], 'in_categories' => { list: 'id' } }`
15
+ # — the arguments a ransackable scope takes, which Ruby cannot report for
16
+ # a `scope` lambda. A scope taking none is `'boolean'`; an undeclared one
17
+ # is published as taking one text value.
18
+ class_attribute :ransackable_scope_types, default: {}
19
+
14
20
  # Associations the Store API may filter through. Empty by default: every
15
21
  # hop is a join into data the storefront was never meant to query, and a
16
22
  # join back to the model it came from lets one filter chain them forever.
@@ -21,6 +27,9 @@ module Spree::RansackableAttributes
21
27
  class_attribute :private_ransackable_attributes, default: {}
22
28
  class_attribute :private_ransackable_scopes, default: {}
23
29
 
30
+ # Attributes the `search` filter matches, set with {.search_by}.
31
+ class_attribute :search_attributes, default: [], instance_accessor: false
32
+
24
33
  class_attribute :default_ransackable_attributes
25
34
  # `position` is included so any `acts_as_list` model is sortable by it
26
35
  # without each subclass having to opt in. Ransack ignores attributes
@@ -48,10 +57,47 @@ module Spree::RansackableAttributes
48
57
  def self.ransackable_scopes(auth_object = nil)
49
58
  base = (whitelisted_ransackable_scopes || []).map(&:to_s)
50
59
  base |= Spree.ransack.custom_scopes_for(self).map(&:to_s)
60
+ base |= %w[search] if search_attributes.any? && search_attributes.all? { |path| ransackable_path?(path, auth_object) }
51
61
  return base unless Spree::RansackableAttributes.restricted_audience?(auth_object)
52
62
 
53
63
  base - Array(private_ransackable_scopes[auth_object.to_sym])
54
64
  end
65
+
66
+ # Declares the `search` filter (`q[search]=term`): a case-insensitive
67
+ # partial match on any of the given attributes, which may reach through
68
+ # associations (`variant_sku`). It runs as Ransack's `_cont` on those
69
+ # attributes, so translated names match in the current locale. An
70
+ # audience that may not filter on every one of them does not get it.
71
+ #
72
+ # search_by :name, :code
73
+ #
74
+ # @param attributes [Array<Symbol, String>]
75
+ def self.search_by(*attributes)
76
+ self.search_attributes = attributes.map(&:to_s).freeze
77
+ scope :search, ->(query) {
78
+ query.blank? ? all : ransack("#{search_attributes.join('_or_')}_cont" => query.to_s.strip).result
79
+ }
80
+ end
81
+
82
+ # Whether an attribute, or one reached through associations
83
+ # (`variant_product_name`), is filterable by this audience.
84
+ #
85
+ # @param path [String]
86
+ # @param auth_object [Symbol, nil]
87
+ # @return [Boolean]
88
+ def self.ransackable_path?(path, auth_object = nil, depth = 0)
89
+ return true if ransackable_attributes(auth_object).include?(path)
90
+ return false if depth >= 2
91
+
92
+ ransackable_associations(auth_object).any? do |name|
93
+ next false unless path.start_with?("#{name}_")
94
+
95
+ reflection = reflect_on_association(name.to_sym)
96
+ next false if reflection.nil? || reflection.polymorphic? || !reflection.klass.respond_to?(:ransackable_path?)
97
+
98
+ reflection.klass.ransackable_path?(path.delete_prefix("#{name}_"), auth_object, depth + 1)
99
+ end
100
+ end
55
101
  end
56
102
 
57
103
  # @param auth_object [Symbol, nil] the Ransack auth object a search ran with
@@ -0,0 +1,54 @@
1
+ module Spree
2
+ # Gives attachment slots their API write name, `<slot>_signed_id`
3
+ # (docs/plans/6.0-uploads-and-file-ownership.md).
4
+ #
5
+ # signed_id_attachments :logo, :mailer_logo
6
+ # store.update(logo_signed_id: upload.signed_id) # attaches
7
+ # store.update(logo_signed_id: nil) # removes
8
+ #
9
+ # A declared slot also refuses a file stored with the other visibility:
10
+ # attaching never moves a file between storage services, so a public upload
11
+ # attached to a private slot would stay readable at a public URL.
12
+ module SignedIdAttachments
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ class_attribute :signed_id_attachment_slots, instance_writer: false, default: []
17
+ end
18
+
19
+ class_methods do
20
+ # @param slots [Array<Symbol>] `has_one_attached` slot names
21
+ # @return [void]
22
+ def signed_id_attachments(*slots)
23
+ validate :signed_id_attachments_on_their_storage if signed_id_attachment_slots.empty?
24
+ self.signed_id_attachment_slots += slots
25
+
26
+ slots.each do |slot|
27
+ define_method(:"#{slot}_signed_id=") { |signed_id| public_send(:"#{slot}=", signed_id) }
28
+ end
29
+ end
30
+
31
+ # The name the API reports +attribute+'s errors under: `logo_signed_id`
32
+ # for the `logo` slot, so a client reads them back under the field it sent.
33
+ #
34
+ # @param attribute [Symbol, String]
35
+ # @return [String, nil] nil when +attribute+ is not a declared slot
36
+ def signed_id_attribute_for(attribute)
37
+ "#{attribute}_signed_id" if signed_id_attachment_slots.include?(attribute.to_sym)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def signed_id_attachments_on_their_storage
44
+ signed_id_attachment_slots.each do |slot|
45
+ blob = attachment_changes[slot.to_s].try(:blob)
46
+ expected_service = self.class.reflect_on_attachment(slot)&.options&.dig(:service_name)
47
+ next if blob.nil? || expected_service.nil? || blob.service_name.to_s == expected_service.to_s
48
+
49
+ visibility = expected_service.to_s == Spree.private_storage_service_name.to_s ? 'private' : 'public'
50
+ errors.add(slot, :wrong_visibility, message: I18n.t('spree.upload_wrong_visibility', visibility: visibility))
51
+ end
52
+ end
53
+ end
54
+ end
@@ -6,7 +6,8 @@ module Spree
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- Spree::Emails::Branding.attribute_names.each { |name| preference :"email_#{name}", :string }
9
+ Spree::Emails::Branding::COLORS.each { |name| preference :"email_#{name}", :string, format: :color }
10
+ preference :email_font, :string, choices: -> { Spree::Emails::Branding::FONTS.keys }
10
11
 
11
12
  validate :email_branding_valid
12
13
  end
@@ -7,6 +7,9 @@ module Spree
7
7
  has_many :markets, class_name: 'Spree::Market', dependent: :destroy
8
8
  has_one :default_market, -> { default }, class_name: 'Spree::Market'
9
9
 
10
+ normalizes :default_locale, with: ->(value) { value.to_s.strip.presence }
11
+ validates_with Spree::LocaleCodesValidator, attributes: %i[default_locale supported_locales]
12
+
10
13
  after_create :ensure_default_market
11
14
  after_update :sync_default_market_settings
12
15
  end
@@ -145,7 +148,7 @@ module Spree
145
148
  markets.create!(
146
149
  name: country.name,
147
150
  currency: read_attribute(:default_currency) || iso_country&.currency_code || 'USD',
148
- default_locale: read_attribute(:default_locale) || iso_country&.languages_official&.first || 'en',
151
+ default_locale: read_attribute(:default_locale) || Spree::Locales.listed(iso_country&.languages_official&.first) || 'en',
149
152
  supported_locales: read_attribute(:supported_locales),
150
153
  default: true,
151
154
  bootstrap_default: true,
@@ -190,7 +193,7 @@ module Spree
190
193
  end
191
194
 
192
195
  def legacy_supported_locales_list
193
- (read_attribute(:supported_locales).to_s.split(',') << default_locale).compact.uniq.sort
196
+ (read_attribute(:supported_locales).to_s.split(',').map(&:strip) << default_locale).compact_blank.uniq.sort
194
197
  end
195
198
  end
196
199
  end
@@ -1,11 +1,14 @@
1
1
  module Spree
2
2
  # Shared shape of the typed adjustment rows (TaxLine, Discount, Fee):
3
3
  # dual-FK cart/order owner (exactly one), consolidated +metadata+ JSON
4
- # column, money display and localized amount parsing.
4
+ # column, money display and strict amount parsing.
5
5
  module TypedAdjustmentLine
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
+ include Spree::CanonicalDecimals
10
+
11
+ canonical_decimals :amount
9
12
  attribute :metadata, default: -> { {} }
10
13
 
11
14
  belongs_to :order, class_name: 'Spree::Order', optional: true
@@ -38,10 +41,6 @@ module Spree
38
41
  end
39
42
  end
40
43
 
41
- def amount=(amount)
42
- self[:amount] = Spree::LocalizedNumber.parse(amount)
43
- end
44
-
45
44
  def currency
46
45
  owner&.currency
47
46
  end
@@ -53,7 +53,7 @@ module Spree
53
53
 
54
54
  def reconcile_typed_association(association, rows)
55
55
  collection = public_send(association)
56
- kept_ids = rows.filter_map { |row| save_typed_association_row(collection, row) }
56
+ kept_ids = rows.each_with_index.filter_map { |row, index| save_typed_association_row(collection, row, "/#{association.to_s.split('_').last}/#{index}") }
57
57
  if kept_ids.any? || rows.empty?
58
58
  collection.where.not(id: kept_ids).destroy_all
59
59
  # `where.not(...).destroy_all` deletes the dropped rows from the DB but
@@ -64,7 +64,8 @@ module Spree
64
64
  end
65
65
  end
66
66
 
67
- def save_typed_association_row(collection, row)
67
+ # @param pointer [String] where the row sits in the request, e.g. `/rules/1`
68
+ def save_typed_association_row(collection, row, pointer)
68
69
  record = find_or_build_typed_association_row(collection, row)
69
70
  return nil unless record
70
71
 
@@ -78,12 +79,10 @@ module Spree
78
79
  deferred_ids, scalar_attrs = attrs.partition { |k, _| record.new_record? && k.to_s.end_with?('_ids') }
79
80
  record.assign_attributes(scalar_attrs.to_h) if scalar_attrs.any?
80
81
 
81
- preferences&.each do |key, value|
82
- next unless record.has_preference?(key.to_sym)
83
-
84
- record.set_preference(key.to_sym, decode_preference_value(key, value))
82
+ record.assign_preferences(preferences, pointer: "#{pointer}/preferences") if preferences
83
+ if calculator.present? && record.respond_to?(:assign_calculator_attributes)
84
+ record.assign_calculator_attributes(calculator, pointer: "#{pointer}/calculator")
85
85
  end
86
- record.assign_calculator_attributes(calculator) if calculator.present? && record.respond_to?(:assign_calculator_attributes)
87
86
 
88
87
  # Always save — `record.changed?` doesn't reflect preferences
89
88
  # (serialized hash) or calculator association changes.
@@ -101,17 +100,6 @@ module Spree
101
100
  raise
102
101
  end
103
102
 
104
- # Decode `*_ids` array preferences (`customer_group_ids`, `user_ids`,
105
- # …) from prefixed strings to raw PKs. Plain-scalar / non-id
106
- # preferences pass through unchanged.
107
- def decode_preference_value(key, value)
108
- return value unless key.to_s.end_with?('_ids') && value.is_a?(Array)
109
-
110
- value.map do |v|
111
- Spree::PrefixedId.prefixed_id?(v) ? Spree::PrefixedId.decode_prefixed_id(v) : v
112
- end
113
- end
114
-
115
103
  def find_or_build_typed_association_row(collection, row)
116
104
  if row[:id].present?
117
105
  id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
@@ -1,6 +1,9 @@
1
1
  module Spree
2
2
  # Restates a VAT-inclusive price for a customer in another country: the
3
3
  # home-country VAT comes out, the destination's goes on.
4
+ #
5
+ # The including class answers +currency+, which the restated amount is
6
+ # rounded to.
4
7
  module VatPriceCalculation
5
8
  # Cheapest test first: a domestic sale needs no restatement whatever the
6
9
  # provider is, so the provider lookup (a constantize and an instantiation) is
@@ -11,7 +14,7 @@ module Spree
11
14
  return amount if amount.nil? || !outside_default_vat_zone?(price_options)
12
15
  return amount unless restatement_available?(price_options)
13
16
 
14
- round_to_two_places(add_foreign_vat_for(amount, price_options))
17
+ Spree::Money::Rounding.to_currency(add_foreign_vat_for(amount, price_options), currency)
15
18
  end
16
19
 
17
20
  private
@@ -66,7 +69,7 @@ module Spree
66
69
  lookup[:country] = country_from(price_options)
67
70
  end
68
71
 
69
- Spree::TaxRate.included_tax_amount_for(lookup).to_f
72
+ Spree::TaxRate.included_tax_amount_for(lookup).to_d
70
73
  end
71
74
 
72
75
  # +current_price_options+ is documented as an override point, so an app may
@@ -92,9 +95,5 @@ module Spree
92
95
 
93
96
  @default_tax_country = Spree::Current.store&.default_country
94
97
  end
95
-
96
- def round_to_two_places(amount)
97
- Spree::Money::Rounding.quantize(amount, 2)
98
- end
99
98
  end
100
99
  end
@@ -206,7 +206,7 @@ module Spree
206
206
  scope.first
207
207
  end
208
208
 
209
- self.whitelisted_ransackable_attributes = ADDRESS_FIELDS + %w[country_code state_code]
209
+ self.whitelisted_ransackable_attributes = ADDRESS_FIELDS - %w[country state] + %w[country_code state_code]
210
210
  self.whitelisted_ransackable_associations = %w[owner]
211
211
 
212
212
  def self.required_fields
@@ -1,6 +1,7 @@
1
1
  module Spree
2
2
  module Adjusters
3
- # Largest-remainder apportionment of an integer cent total across weights.
3
+ # Largest-remainder apportionment of an integer total, in the currency's
4
+ # minor units, across weights.
4
5
  # Shared by promotion order-level distribution and manual admin discounts
5
6
  # so both split identically (sum of shares always equals the total).
6
7
  module LargestRemainder
@@ -19,6 +20,18 @@ module Spree
19
20
  end
20
21
  shares
21
22
  end
23
+
24
+ # Splits an amount across weights in the currency's minor units, so the
25
+ # shares are amounts that sum exactly to the one given.
26
+ #
27
+ # @param amount [BigDecimal]
28
+ # @param weights [Array<Numeric>] proportional bases (sum must be > 0)
29
+ # @param currency [String]
30
+ # @return [Array<BigDecimal>]
31
+ def apportion(amount, weights, currency)
32
+ units = Spree::Money::Rounding.to_minor_units(amount, currency)
33
+ largest_remainder_shares(units, weights).map { |share| Spree::Money::Rounding.from_minor_units(share, currency) }
34
+ end
22
35
  end
23
36
  end
24
37
  end
@@ -140,7 +140,7 @@ module Spree
140
140
  end
141
141
  end
142
142
 
143
- # Largest-remainder distribution (in cents) of the order-wide winner over
143
+ # Largest-remainder distribution (in minor units) of the order-wide winner over
144
144
  # each line's remaining discounted base, so shares always sum exactly to
145
145
  # the promotion amount and no line goes below zero.
146
146
  def distribute_over_line_items(chosen)
@@ -149,11 +149,10 @@ module Spree
149
149
  bases_sum = bases.sum
150
150
  return if bases_sum <= 0
151
151
 
152
- total_cents = [chosen[:amount].abs, bases_sum].min * 100
153
- shares = Spree::Adjusters::LargestRemainder.largest_remainder_shares(total_cents.round, bases)
152
+ shares = Spree::Adjusters::LargestRemainder.apportion([chosen[:amount].abs, bases_sum].min, bases, order.currency)
154
153
 
155
154
  line_items.each_with_index do |line_item, index|
156
- amount = -BigDecimal(shares[index]) / 100
155
+ amount = -shares[index]
157
156
  next if amount.zero?
158
157
 
159
158
  persist_discount(line_item, chosen, amount)
@@ -10,6 +10,9 @@ module Spree
10
10
 
11
11
  include Spree::SingleStoreResource
12
12
 
13
+ self.whitelisted_ransackable_attributes = %w[origin]
14
+ search_by :origin
15
+
13
16
 
14
17
  validates :origin, presence: true
15
18
  validates :origin, uniqueness: { scope: [:store_id, *spree_base_uniqueness_scope] }
@@ -9,6 +9,7 @@ class Spree::Base < ApplicationRecord
9
9
  include Spree::HasNumber
10
10
  include Spree::HasIsoGeography
11
11
  include Spree::TypedAssociations
12
+ include Spree::SignedIdAttachments
12
13
  include Spree::ValidatesStoreUniqueness
13
14
 
14
15
  # Extra writable attributes contributed by extensions, appended to the v3
@@ -68,6 +69,18 @@ class Spree::Base < ApplicationRecord
68
69
  true
69
70
  end
70
71
 
72
+ # Whether to answer from an association's rows rather than a query of its
73
+ # own (a SUM, an EXISTS). True when the rows are loaded already, or when
74
+ # this record came from a list loaded with +preload_associations_lazily+:
75
+ # loading them then fetches the rows for the whole list in one query, where
76
+ # an aggregate would run once per record.
77
+ #
78
+ # @param name [Symbol] the association
79
+ # @return [Boolean]
80
+ def read_association_in_memory?(name)
81
+ association(name).loaded? || lazy_preload_context.present?
82
+ end
83
+
71
84
  # @see Spree.mysql? — the single definition; this reads it so a model can
72
85
  # branch without reaching for the connection itself.
73
86
  def mysql_adapter?
@@ -151,6 +164,22 @@ class Spree::Base < ApplicationRecord
151
164
  end
152
165
  end
153
166
 
167
+ # How a filter on one of this model's columns turns the short name a client
168
+ # sends (`purchase_order`) into the class name the column stores, or nil
169
+ # when the column does not store class names. Covers the STI column of a
170
+ # typed family and polymorphic `*_type` columns; a model with another such
171
+ # column overrides this.
172
+ #
173
+ # @param attribute [String]
174
+ # @return [Proc, nil] short name → stored class name
175
+ def self.api_type_resolver(attribute)
176
+ if attribute == inheritance_column && api_type_registry.any?
177
+ ->(api_type) { class_name_for_api_type(api_type) }
178
+ elsif reflect_on_all_associations(:belongs_to).any? { |reflection| reflection.polymorphic? && reflection.foreign_type == attribute }
179
+ ->(api_type) { Spree::Base.polymorphic_type_for(api_type) }
180
+ end
181
+ end
182
+
154
183
  # Shorthand for a *polymorphic* `*_type` column (`owner_type`,
155
184
  # `viewable_type`, …), where the value names an arbitrary model rather than a
156
185
  # subclass of the serialized one — so `api_type_for`'s registry lookup does
@@ -7,7 +7,7 @@ module Spree
7
7
  end
8
8
 
9
9
  def compute(object)
10
- computed_amount = (object.amount * preferred_flat_percent / 100).round(2)
10
+ computed_amount = Spree::Money::Rounding.to_currency(object.amount * preferred_flat_percent / 100, object.try(:currency))
11
11
 
12
12
  # We don't want to cause the promotion adjustments to push the order into a negative total.
13
13
  if computed_amount > object.amount
@@ -1,7 +1,7 @@
1
1
  module Spree
2
2
  class Calculator::FlatRate < Calculator
3
- preference :amount, :decimal, default: 0
4
- preference :currency, :string, default: -> { Spree::Store.default.default_currency }
3
+ preference :amount, :money, default: 0
4
+ preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
5
5
  preference :apply_only_on_full_priced_items, :boolean, default: false
6
6
 
7
7
  def self.description
@@ -1,9 +1,9 @@
1
1
  module Spree
2
2
  class Calculator::FlexiRate < Calculator
3
- preference :first_item, :decimal, default: 0.0
4
- preference :additional_item, :decimal, default: 0.0
3
+ preference :first_item, :money, default: 0.0
4
+ preference :additional_item, :money, default: 0.0
5
5
  preference :max_items, :integer, default: 0
6
- preference :currency, :string, default: -> { Spree::Store.default.default_currency }
6
+ preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
7
7
  preference :apply_only_on_full_priced_items, :boolean, default: false
8
8
 
9
9
  def self.description
@@ -11,7 +11,7 @@ module Spree
11
11
  def compute(object)
12
12
  return 0 if preferred_apply_only_on_full_priced_items && object.variant.compare_at_amount_in(object.currency).present?
13
13
 
14
- computed_amount = (object.amount * preferred_percent / 100).round(2)
14
+ computed_amount = Spree::Money::Rounding.to_currency(object.amount * preferred_percent / 100, object.currency)
15
15
 
16
16
  # We don't want to cause the promotion adjustments to push the order into a negative total.
17
17
  if computed_amount > object.amount
@@ -1,9 +1,9 @@
1
1
  module Spree
2
2
  class Calculator::PriceSack < Calculator
3
- preference :minimal_amount, :decimal, default: 0
4
- preference :normal_amount, :decimal, default: 0
5
- preference :discount_amount, :decimal, default: 0
6
- preference :currency, :string, default: -> { Spree::Store.default.default_currency }
3
+ preference :minimal_amount, :money, default: 0
4
+ preference :normal_amount, :money, default: 0
5
+ preference :discount_amount, :money, default: 0
6
+ preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
7
7
 
8
8
  def self.description
9
9
  I18n.t('spree.price_sack')
@@ -3,8 +3,8 @@ module Spree
3
3
  class DigitalDelivery < ShippingCalculator
4
4
  include Spree::Calculator::CurrencyAmounts
5
5
 
6
- preference :amount, :decimal, default: 0
7
- preference :currency, :string, default: -> { Spree::Store.default.default_currency }
6
+ preference :amount, :money, default: 0
7
+ preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
8
8
 
9
9
  def self.description
10
10
  I18n.t('spree.digital.digital_delivery')
@@ -8,12 +8,14 @@ module Spree
8
8
  end
9
9
 
10
10
  def compute_package(package)
11
- compute_from_price(total(package.contents))
11
+ compute_from_price(total(package.contents), package.currency)
12
12
  end
13
13
 
14
- def compute_from_price(price)
15
- value = price * BigDecimal(preferred_flat_percent.to_s) / 100.0
16
- (value * 100).round.to_f / 100
14
+ # @param price [BigDecimal]
15
+ # @param currency [String, nil] the amount is rounded to its minor unit
16
+ # @return [BigDecimal]
17
+ def compute_from_price(price, currency = nil)
18
+ Spree::Money::Rounding.to_currency(price * BigDecimal(preferred_flat_percent.to_s) / 100, currency)
17
19
  end
18
20
  end
19
21
  end