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
@@ -41,11 +41,13 @@ module Spree
41
41
  #
42
42
  # Preferences
43
43
  #
44
+ UNIT_SYSTEMS = %w[metric imperial].freeze
45
+
44
46
  # general preferences
45
- preference :admin_locale, :string
46
- preference :timezone, :string, default: Time.zone.name
47
- preference :weight_unit, :string, default: 'lb'
48
- preference :unit_system, :string, default: 'imperial'
47
+ preference :admin_locale, :string, format: :locale
48
+ preference :timezone, :string, format: :timezone, default: Time.zone.name
49
+ preference :weight_unit, :string, default: 'lb', choices: -> { Spree::Variant::WEIGHT_UNITS }
50
+ preference :unit_system, :string, default: 'imperial', choices: -> { UNIT_SYSTEMS }
49
51
  # Anonymous identifier of this installation, kept on the default store
50
52
  # (see Spree.install_id). Written by Spree, never by the operator.
51
53
  preference :install_id, :string, internal: true, nullable: true
@@ -83,22 +85,22 @@ module Spree
83
85
  # How often sellers are settled, unless one carries its own schedule.
84
86
  # Monthly by default because it is the interval that needs least of an
85
87
  # operator paying by hand, which is what the built-in provider expects.
86
- preference :default_payouts_schedule_interval, :string, default: 'monthly'
88
+ preference :default_payouts_schedule_interval, :string, default: 'monthly', choices: -> { Spree::Seller::PAYOUT_INTERVALS }
87
89
  validates :preferred_default_payouts_schedule_interval,
88
90
  inclusion: { in: Spree::Seller::PAYOUT_INTERVALS }, allow_blank: true
89
91
  # What a seller's balance must reach before a settlement is worth sending;
90
92
  # below it the balance carries to the next period. Zero pays whatever is
91
93
  # owed, which is right for a provider that moves money for free.
92
- preference :default_minimum_payout_amount, :decimal, default: 0
94
+ preference :default_minimum_payout_amount, :money, default: 0
93
95
  # Checkout preferences
94
96
  # Store-level fallback for the channel-owned `guest_checkout` preference
95
97
  # (see Spree::Channel::Gating). Retained so existing accessors keep working.
96
98
  preference :guest_checkout, :boolean, default: true
97
99
  # Store-level fallback for the channel-owned `storefront_access` posture.
98
- preference :storefront_access, :string, default: 'public'
100
+ preference :storefront_access, :string, default: 'public', choices: -> { Spree::Channel::Gating::STOREFRONT_ACCESS }
99
101
  # Canonical storefront origin used in customer-facing emails and links,
100
102
  # e.g. "https://myshop.com" — see #storefront_url for the fallback chain.
101
- preference :storefront_url, :string
103
+ preference :storefront_url, :string, format: :url
102
104
  preference :special_instructions_enabled, :boolean, default: false
103
105
  # Advertises a confirm/review step for every checkout, even when no
104
106
  # payment method asks for one. Payment methods that require confirmation
@@ -108,7 +110,8 @@ module Spree
108
110
  # Store-wide default for when a customer is charged rather than only
109
111
  # authorized. A payment method's own capture_method wins when set.
110
112
  # See Spree::CaptureMethod.
111
- preference :capture_method, :string, default: Spree::CaptureMethod::DEFAULT_CAPTURE_METHOD
113
+ preference :capture_method, :string, default: Spree::CaptureMethod::DEFAULT_CAPTURE_METHOD,
114
+ choices: -> { Spree::CaptureMethod::CAPTURE_METHODS }
112
115
  preference :stock_reservations_enabled, :boolean, default: true
113
116
  # Which address a sale's tax is computed against. Stores selling where tax
114
117
  # follows the destination keep this on; billing-address jurisdictions turn
@@ -120,9 +123,9 @@ module Spree
120
123
  preference :inventory_provider, :string, default: 'internal'
121
124
  # What happens when an external source cannot be reached.
122
125
  preference :pricing_provider_failure_policy, :string,
123
- default: Spree::ProviderFailurePolicy::DEFAULT_PRICING_POLICY
126
+ default: Spree::ProviderFailurePolicy::DEFAULT_PRICING_POLICY, choices: -> { Spree::ProviderFailurePolicy::VALUES }
124
127
  preference :inventory_provider_failure_policy, :string,
125
- default: Spree::ProviderFailurePolicy::DEFAULT_INVENTORY_POLICY
128
+ default: Spree::ProviderFailurePolicy::DEFAULT_INVENTORY_POLICY, choices: -> { Spree::ProviderFailurePolicy::VALUES }
126
129
  # Catalog preferences
127
130
  preference :track_inventory_levels, :boolean, default: true
128
131
  # On-hand quantity at or below which a tracked variant counts as running
@@ -163,11 +166,27 @@ module Spree
163
166
  # applies to every numbered document; prefix, suffix and starting value
164
167
  # are order-only — other document types keep their code-level prefixes.
165
168
  # Changes affect future numbers only; existing numbers are permanent.
166
- preference :document_number_format, :string, default: 'sequential'
169
+ preference :document_number_format, :string, default: 'sequential', choices: -> { Spree::NumberGenerators::Registry::FORMATS.keys }
167
170
  preference :order_number_prefix, :string, default: 'R'
168
171
  preference :order_number_suffix, :string, default: ''
169
172
  preference :order_number_sequence_start, :integer, default: 1001
170
173
 
174
+ # The settings the API reads and writes under their plain names.
175
+ exposes_preferences :admin_locale, :timezone, :storefront_url, :weight_unit, :unit_system, :storefront_access, :guest_checkout,
176
+ :always_include_confirm_step, :company_field_enabled, :address_requires_company,
177
+ :address_requires_phone, :capture_method, :track_inventory_levels, :stock_reservations_enabled,
178
+ :low_stock_threshold, :tax_using_ship_address, :track_price_history,
179
+ :show_products_without_price, :disable_sku_validation, :order_routing_strategy,
180
+ :pricing_provider, :inventory_provider, :pricing_provider_failure_policy,
181
+ :inventory_provider_failure_policy, :payout_provider, :default_payouts_schedule_interval,
182
+ :default_minimum_payout_amount, :auto_approve_sellers, :auto_approve_seller_products,
183
+ :send_seller_transactional_emails, :default_commission_tax_rate, :document_number_format,
184
+ :order_number_prefix, :order_number_suffix, :order_number_sequence_start,
185
+ :limit_digital_download_count, :digital_asset_authorized_clicks,
186
+ :limit_digital_download_days, :digital_asset_authorized_days,
187
+ :send_consumer_transactional_emails, :email_accent_color, :email_background_color,
188
+ :email_card_color, :email_text_color, :email_heading_color, :email_font
189
+
171
190
  #
172
191
  # Associations
173
192
  #
@@ -343,6 +362,10 @@ module Spree
343
362
  validates :preferred_default_commission_tax_rate,
344
363
  numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }
345
364
  validates :preferred_storefront_access, inclusion: { in: Spree::Channel::Gating::STOREFRONT_ACCESS }
365
+ # Blank falls back to the defaults where they are read, so a store saved
366
+ # with one before these checks existed stays savable.
367
+ validates :preferred_unit_system, inclusion: { in: UNIT_SYSTEMS }, allow_blank: true
368
+ validates :preferred_weight_unit, inclusion: { in: Spree::Variant::WEIGHT_UNITS }, allow_blank: true
346
369
  validates :preferred_document_number_format,
347
370
  inclusion: { in: Spree::NumberGenerators::Registry::FORMATS.keys }
348
371
  validates :preferred_order_number_sequence_start,
@@ -370,6 +393,7 @@ module Spree
370
393
  #
371
394
  has_one_attached :logo, service: Spree.public_storage_service_name
372
395
  has_one_attached :mailer_logo, service: Spree.public_storage_service_name
396
+ signed_id_attachments :logo, :mailer_logo
373
397
 
374
398
  # First-run setup credential (docs/plans/6.0-store-context-and-first-run-setup.md):
375
399
  # printed by the installer, only consulted by the setup endpoint while no
@@ -528,6 +552,7 @@ module Spree
528
552
  end
529
553
 
530
554
  # Returns the storefront origin URL for use in customer-facing emails and links.
555
+ # Replaces the plain `storefront_url` reader `exposes_preferences` defines.
531
556
  # Uses the `storefront_url` preference when set, then the oldest non-loopback
532
557
  # allowed origin (the `http://localhost` origin seeded on install must never
533
558
  # leak into customer emails), otherwise falls back to formatted_url.
@@ -5,6 +5,9 @@ module Spree
5
5
  include Spree::SingleStoreResource
6
6
  include Spree::HasCustomFields
7
7
  include Spree::Metadata
8
+ include Spree::CanonicalDecimals
9
+
10
+ canonical_decimals :amount
8
11
 
9
12
  publishes_lifecycle_events
10
13
 
@@ -105,13 +108,11 @@ module Spree
105
108
  extend Spree::DisplayMoney
106
109
  money_methods :amount, :amount_used, :amount_remaining, :amount_authorized
107
110
 
108
- def amount=(amount)
109
- self[:amount] = Spree::LocalizedNumber.parse(amount)
110
- end
111
-
112
111
  self.whitelisted_ransackable_attributes = %w[customer_id created_by_id amount currency memo]
112
+ search_by :memo
113
113
  self.whitelisted_ransackable_associations = %w[customer created_by]
114
114
  self.whitelisted_ransackable_scopes = %w[outstanding from_gift_card]
115
+ self.ransackable_scope_types = %w[outstanding from_gift_card].index_with('boolean')
115
116
  # Staff notes and who issued the credit are back-office data.
116
117
  self.private_ransackable_attributes = { store: %w[memo created_by_id] }
117
118
 
@@ -32,6 +32,7 @@ module Spree
32
32
  normalizes :name, :contact_name, :phone, with: ->(value) { value.strip }
33
33
 
34
34
  self.whitelisted_ransackable_attributes = %w[name contact_name email phone city country_code created_at]
35
+ search_by :name, :contact_name, :email
35
36
 
36
37
  # The postal address, built from this row's own columns the way
37
38
  # {Spree::StockLocation#address} does. Unsaved, and blank when nothing has
@@ -16,6 +16,7 @@ module Spree
16
16
  before_save :set_default_category
17
17
 
18
18
  self.whitelisted_ransackable_attributes = %w[name is_default tax_code]
19
+ search_by :name
19
20
 
20
21
  # The category applied to taxable records that carry none of their own.
21
22
  #
@@ -27,6 +27,7 @@ module Spree
27
27
  # Confidential, so the private service rather than the public one that
28
28
  # serves product images.
29
29
  has_one_attached :document, service: Spree.private_storage_service_name
30
+ signed_id_attachments :document
30
31
 
31
32
  validates :certificate_number, :reason_code, presence: true
32
33
  # Certificates exist only on legal-entity nodes — a division's purchases
@@ -136,7 +136,7 @@ module Spree
136
136
  write_tax_line(owner, item, rate, 0, 'customer_exempt', jurisdiction,
137
137
  data: exemption_data(exemption, exemption_subject))
138
138
  else
139
- write_tax_line(owner, item, rate, compute_tax(rate, item, relevant_rates),
139
+ write_tax_line(owner, item, rate, compute_tax(rate, item, relevant_rates, owner.currency),
140
140
  reason_for(rate), jurisdiction)
141
141
  end
142
142
  end
@@ -169,16 +169,19 @@ module Spree
169
169
  item.respond_to?(:taxable_basis) ? item.taxable_basis : item.amount
170
170
  end
171
171
 
172
- def compute_tax(rate, item, relevant_rates)
172
+ # Rounded per line, half up, to the order's currency — the internal
173
+ # provider's choice; other providers round as their jurisdictions require.
174
+ def compute_tax(rate, item, relevant_rates, currency)
173
175
  basis = taxable_basis(item)
174
176
 
175
- if rate.included_in_price
176
- included_sum = relevant_rates.select(&:included_in_price).sum(&:amount)
177
- included_sum = rate.amount if included_sum.zero?
178
- (basis / (1 + included_sum) * rate.amount).round(2)
179
- else
180
- (basis * rate.amount).round(2)
181
- end
177
+ tax = if rate.included_in_price
178
+ included_sum = relevant_rates.select(&:included_in_price).sum(&:amount)
179
+ included_sum = rate.amount if included_sum.zero?
180
+ basis / (1 + included_sum) * rate.amount
181
+ else
182
+ basis * rate.amount
183
+ end
184
+ Spree::Money::Rounding.to_currency(tax, currency)
182
185
  end
183
186
 
184
187
  # Pre-tax amounts are stored so refund and reporting code can weigh
@@ -15,6 +15,10 @@ module Spree
15
15
  include Spree::HasCustomFields
16
16
  include Spree::Metadata
17
17
  include Spree::SingleStoreResource
18
+ include Spree::CanonicalDecimals
19
+
20
+ # A fraction (0.07125), held to the column's five decimals.
21
+ canonical_decimals :amount, places: 5
18
22
 
19
23
  with_options inverse_of: :tax_rates do
20
24
  belongs_to :tax_category,
@@ -56,17 +60,36 @@ module Spree
56
60
  ->(category) { where(tax_category_id: category.try(:id)) }
57
61
  scope :included_in_price, -> { where(included_in_price: true) }
58
62
 
59
- self.whitelisted_ransackable_attributes = %w[amount country_code state_code tax_category_id included_in_price name]
63
+ self.whitelisted_ransackable_attributes = %w[amount rate country_code state_code tax_category_id included_in_price name]
64
+ ransack_alias :rate, :amount
65
+ search_by :name
60
66
 
61
- # Virtual attribute for percentage display in admin forms
62
- def amount_percentage
63
- return nil if amount.nil?
67
+ # The rate as a fraction (0.23); the column is +amount+.
68
+ alias_attribute :rate, :amount
69
+
70
+ # The rate as a percentage (23), what a merchant types.
71
+ #
72
+ # @return [BigDecimal, nil]
73
+ def rate_percent
74
+ amount && amount * 100
75
+ end
76
+
77
+ # @param value [String, Numeric, nil]
78
+ def rate_percent=(value)
79
+ parsed = Spree::Money::Rounding.parse_decimal(value)
80
+ self.amount = parsed && parsed / 100
81
+ end
64
82
 
65
- (amount * 100).round(2)
83
+ # @deprecated Use {#rate_percent}; removed in 6.1.
84
+ def amount_percentage
85
+ Spree::Deprecation.warn('Spree::TaxRate#amount_percentage is deprecated and will be removed in Spree 6.1. Use #rate_percent instead.')
86
+ rate_percent
66
87
  end
67
88
 
89
+ # @deprecated Use {#rate_percent=}; removed in 6.1.
68
90
  def amount_percentage=(value)
69
- self.amount = value.present? ? (value.to_f / 100) : nil
91
+ Spree::Deprecation.warn('Spree::TaxRate#amount_percentage= is deprecated and will be removed in Spree 6.1. Use #rate_percent= instead.')
92
+ self.rate_percent = value
70
93
  end
71
94
 
72
95
  # The included-in-price rate to back out of a gross price.
@@ -10,7 +10,7 @@ module Spree
10
10
  # prefix (ctg), and model_name are all Spree::Category. Only the constant differs,
11
11
  # so is_a?, STI, polymorphic *_type strings, and class_name: references keep
12
12
  # resolving to Spree::Category. The warning below fires through Spree's deprecator
13
- # when this file loads (at boot under eager load, on first reference otherwise).
13
+ # when this file loads (on first reference; it is kept out of eager loading).
14
14
  Taxon = Category
15
15
 
16
16
  Spree::Deprecation.warn('Spree::Taxon is deprecated and will be removed in Spree 6.1. Use Spree::Category instead.')
@@ -0,0 +1,25 @@
1
+ module Spree
2
+ # What {Spree::UpdateCheck} last learned about newer releases. Never
3
+ # persisted.
4
+ class UpdateStatus
5
+ include ActiveModel::Model
6
+ include ActiveModel::Attributes
7
+
8
+ attribute :current_version, :string
9
+ attribute :enabled, :boolean, default: true
10
+ # Nil until the first check has finished.
11
+ attribute :checked_at, :datetime
12
+ # Newer releases, newest first: hashes with +version+, +url+ and +published_at+.
13
+ attribute :releases, default: -> { [] }
14
+
15
+ # @return [Hash, nil]
16
+ def latest_release
17
+ releases.first
18
+ end
19
+
20
+ # @return [Boolean]
21
+ def update_available?
22
+ latest_release.present?
23
+ end
24
+ end
25
+ end
@@ -8,6 +8,7 @@ module Spree
8
8
  include Spree::MemoizedData
9
9
  include Spree::HasCustomFields
10
10
  include Spree::Metadata
11
+ include Spree::CanonicalDecimals
11
12
  include Spree::HasExternalReferences
12
13
  include Spree::Searchable
13
14
  include Spree::StorePreferences
@@ -289,14 +290,9 @@ module Spree
289
290
  relation.where(conditions.reduce(:or)).distinct
290
291
  end
291
292
 
292
- # FIXME: cost price should be represented with DisplayMoney class
293
- LOCALIZED_NUMBERS = %w(cost_price weight depth width height)
294
-
295
- LOCALIZED_NUMBERS.each do |m|
296
- define_method("#{m}=") do |argument|
297
- self[m] = Spree::LocalizedNumber.parse(argument) if argument.present?
298
- end
299
- end
293
+ # A blank value leaves these as they were rather than clearing them.
294
+ canonical_decimals :cost_price, places: Spree::Money::Rounding::UNIT_PRICE_DECIMALS, ignore_blank: true
295
+ canonical_decimals :weight, :depth, :width, :height, places: nil, ignore_blank: true
300
296
 
301
297
  accepts_nested_attributes_for(
302
298
  :stock_levels,
@@ -331,6 +327,7 @@ module Spree
331
327
  carton_package_type_id carton_weight cartons_per_pallet]
332
328
  self.whitelisted_ransackable_scopes = %i(product_name_or_sku_cont search_by_product_name_or_sku search
333
329
  available_at_stock_location)
330
+ self.ransackable_scope_types = { 'available_at_stock_location' => 'id' }
334
331
  self.private_ransackable_attributes = { store: %w[cost_price cost_currency deleted_at] }
335
332
 
336
333
  def self.product_name_or_sku_cont(query)
@@ -666,7 +663,7 @@ module Spree
666
663
  def price_in(currency)
667
664
  currency = currency&.upcase
668
665
 
669
- price = if prices.loaded? && prices.any?
666
+ price = if read_association_in_memory?(:prices) && prices.any?
670
667
  prices.detect { |p| p.currency == currency && p.price_list_id.nil? }
671
668
  else
672
669
  prices.base_prices.find_by(currency: currency)
@@ -25,6 +25,7 @@ module Spree
25
25
 
26
26
  # Ransack configuration
27
27
  self.whitelisted_ransackable_attributes = %w[event_name response_code execution_time success delivered_at created_at]
28
+ search_by :event_name
28
29
 
29
30
  # Event subjects whose permission is not found by class name alone.
30
31
  PAYLOAD_SUBJECT_CLASSES = {
@@ -30,11 +30,45 @@ module Spree
30
30
  before_save :clear_disabled_state_when_reactivated
31
31
 
32
32
  self.whitelisted_ransackable_attributes = %w[name url active]
33
+ search_by :url
33
34
 
34
35
  scope :active, -> { where(active: true) }
35
36
  scope :inactive, -> { where(active: false) }
36
37
  scope :enabled, -> { active.where(disabled_at: nil) }
37
38
 
39
+ # Whether +url+ targets a host the operator trusts despite it resolving to
40
+ # a private address — e.g. a storefront Service in the same Kubernetes
41
+ # cluster. Set via +Spree::Api::Config.webhooks_allowed_internal_hosts+
42
+ # (SPREE_WEBHOOKS_ALLOWED_INTERNAL_HOSTS): an entry matches that exact
43
+ # host, or with a leading dot (".svc.cluster.local") any host under it.
44
+ # Matching is case-insensitive. Operator config only, so a store admin
45
+ # can never point a webhook at an internal address.
46
+ #
47
+ # @param url [String]
48
+ # @return [Boolean]
49
+ def self.allowed_internal_host?(url)
50
+ host = URI.parse(url.to_s).hostname&.downcase
51
+ return false if host.blank?
52
+
53
+ allowed_internal_hosts.any? do |entry|
54
+ entry.start_with?('.') ? host.end_with?(entry) : host == entry
55
+ end
56
+ rescue URI::InvalidURIError
57
+ false
58
+ end
59
+
60
+ # Spree::Api::Config only exists when spree_api is loaded; without it no
61
+ # webhook is ever delivered, so there is nothing to allow.
62
+ def self.allowed_internal_hosts
63
+ return [] unless defined?(Spree::Api::Config)
64
+
65
+ Array(Spree::Api::Config.webhooks_allowed_internal_hosts).filter_map do |entry|
66
+ entry = entry.to_s.strip.downcase
67
+ entry if entry.present? && entry != '.'
68
+ end
69
+ end
70
+ private_class_method :allowed_internal_hosts
71
+
38
72
  # Returns the plaintext `secret_key` only on the create response.
39
73
  #
40
74
  # `@reveal_secret_in_response` is set by the `after_create` callback above
@@ -165,6 +199,8 @@ module Spree
165
199
  end
166
200
 
167
201
  def url_must_not_resolve_to_private_ip
202
+ return if self.class.allowed_internal_host?(url)
203
+
168
204
  uri = URI.parse(url)
169
205
  blacklist = SsrfFilter::IPV4_BLACKLIST + SsrfFilter::IPV6_BLACKLIST
170
206
  addresses = Resolv.getaddresses(uri.host)
@@ -96,16 +96,16 @@ module Spree
96
96
  index.zero? ? order.email : nil,
97
97
  index.zero? ? order.status : nil,
98
98
  index.zero? ? order.currency : nil,
99
- index.zero? ? order.item_total.to_f : nil,
100
- index.zero? ? order.delivery_total.to_f : nil,
101
- index.zero? ? order.tax_total.to_f : nil,
99
+ index.zero? ? money(order.item_total) : nil,
100
+ index.zero? ? money(order.delivery_total) : nil,
101
+ index.zero? ? money(order.tax_total) : nil,
102
102
  index.zero? ? order.included_tax_total.positive? : nil,
103
103
  index.zero? ? (order.discount_total.negative? || line_item.discount_total.negative?) : nil,
104
104
  index.zero? ? order.has_free_shipping? : nil,
105
- index.zero? ? order.discount_total.abs : nil,
105
+ index.zero? ? money(order.discount_total.abs) : nil,
106
106
  index.zero? ? order.promo_code : nil,
107
- index.zero? ? order.payments.store_credits.sum(:amount).abs : nil,
108
- index.zero? ? order.total.to_f : nil,
107
+ index.zero? ? money(order.payments.store_credits.sum(:amount).abs) : nil,
108
+ index.zero? ? money(order.total) : nil,
109
109
  index.zero? ? order.delivery_method&.name : nil,
110
110
  index.zero? ? order.total_weight.to_f : nil,
111
111
  index.zero? ? order.payments.valid&.first&.display_source_name : nil,
@@ -160,6 +160,10 @@ module Spree
160
160
 
161
161
  private
162
162
 
163
+ def money(amount)
164
+ Spree::Money::Rounding.format(amount, order.currency)
165
+ end
166
+
163
167
  # Drops the cells whose headers were omitted, matching each value to its
164
168
  # header by position so the row and the header list stay aligned.
165
169
  def reject_omitted(csv)
@@ -26,15 +26,10 @@ module Spree
26
26
 
27
27
  private
28
28
 
29
- # The currency's own precision, a dot for the decimal mark and nothing
30
- # else — "18.00", never "$18.00", "18.0" or "18,00". Fixed rather than
31
- # localized because the file is read back by the import under the
32
- # store's locale, not the exporting admin's, and a comma written here
33
- # would re-import as a hundred times the price.
29
+ # A plain decimal ("18.00", never "$18.00" or "18,00"): the import reads
30
+ # the file back and accepts nothing else.
34
31
  def amount_string(amount)
35
- return nil if amount.nil?
36
-
37
- format("%.#{Spree::Money::Rounding.precision(price.currency)}f", amount)
32
+ Spree::Money::Rounding.format(amount, price.currency, unit_price: true)
38
33
  end
39
34
  end
40
35
  end
@@ -98,17 +98,17 @@ module Spree
98
98
  index.zero? ? product.meta_keywords : nil,
99
99
  index.zero? ? product.tag_list.to_s : nil,
100
100
  index.zero? ? product.label_list.to_s : nil,
101
- variant.amount_in(currency).to_f,
102
- variant.compare_at_amount_in(currency).to_f,
101
+ unit_price(variant.amount_in(currency) || 0),
102
+ unit_price(variant.compare_at_amount_in(currency) || 0),
103
103
  currency,
104
- variant.width,
105
- variant.height,
106
- variant.depth,
104
+ decimal(variant.width),
105
+ decimal(variant.height),
106
+ decimal(variant.depth),
107
107
  # The stored columns, not the resolved readers: importing this file
108
108
  # into a store on another unit system must not silently write this
109
109
  # store's units onto every variant.
110
110
  variant[:dimensions_unit],
111
- variant.weight,
111
+ decimal(variant.weight),
112
112
  variant[:weight_unit],
113
113
  publication_available_on&.strftime('%Y-%m-%d %H:%M:%S'),
114
114
  (variant.discontinue_on || publication_discontinue_on)&.strftime('%Y-%m-%d %H:%M:%S'),
@@ -151,6 +151,15 @@ module Spree
151
151
 
152
152
  private
153
153
 
154
+ def unit_price(amount)
155
+ Spree::Money::Rounding.format(amount, currency, unit_price: true)
156
+ end
157
+
158
+ # "1.5", never Ruby's "0.15e1", which the import refuses.
159
+ def decimal(value)
160
+ Spree::Money::Rounding.format_decimal(value)
161
+ end
162
+
154
163
  # Default-channel publication for the export's store. 5.5 transitional:
155
164
  # fall back to the legacy Product columns when the publication dates are
156
165
  # NULL (pre-backfill). 6.0 drops the Product-column fallback.
@@ -175,8 +184,8 @@ module Spree
175
184
  csv = Array.new(CSV_HEADERS.size)
176
185
  csv[CSV_HEADERS.index('sku')] = variant.sku
177
186
  csv[CSV_HEADERS.index('slug')] = product.slug
178
- csv[CSV_HEADERS.index('price')] = variant.amount_in(currency)&.to_f
179
- csv[CSV_HEADERS.index('compare_at_price')] = variant.compare_at_amount_in(currency)&.to_f
187
+ csv[CSV_HEADERS.index('price')] = unit_price(variant.amount_in(currency))
188
+ csv[CSV_HEADERS.index('compare_at_price')] = unit_price(variant.compare_at_amount_in(currency))
180
189
  csv[CSV_HEADERS.index('currency')] = currency
181
190
  csv
182
191
  end
@@ -41,9 +41,7 @@ module Spree
41
41
  private
42
42
 
43
43
  def amount_string(amount, currency)
44
- return nil if amount.nil?
45
-
46
- format("%.#{Spree::Money::Rounding.precision(currency)}f", amount)
44
+ Spree::Money::Rounding.format(amount, currency, unit_price: true)
47
45
  end
48
46
  end
49
47
  end
@@ -89,7 +89,7 @@ module Spree
89
89
  cart.use_shipping = params[:use_shipping] if params.key?(:use_shipping)
90
90
  assign_preferred_stock_location if params.key?(:preferred_stock_location_id)
91
91
  assign_company if params.key?(:company_id)
92
- assign_po_document if params.key?(:po_document)
92
+ assign_po_document if params.key?(:po_document_signed_id)
93
93
  end
94
94
 
95
95
  # The buyer's purchase order as an ActiveStorage signed blob id — the
@@ -97,12 +97,12 @@ module Spree
97
97
  # value detaches, so a buyer can withdraw a document they attached in
98
98
  # error.
99
99
  #
100
- # An abandoned direct upload leaves the blob row behind without its
100
+ # An abandoned presigned upload leaves the blob row behind without its
101
101
  # bytes, and the buyer's client may still send back the signed id it was
102
102
  # handed. ActiveStorage raises a message-less FileNotFoundError for that,
103
103
  # which would otherwise reach the storefront as a bare class name.
104
104
  def assign_po_document
105
- value = params[:po_document]
105
+ value = params[:po_document_signed_id]
106
106
 
107
107
  if value.blank?
108
108
  cart.po_document.detach
@@ -4,9 +4,13 @@ module Spree
4
4
  class ProductVariant < Base
5
5
  OPTION_TYPES_COUNT = 3
6
6
  CATEGORY_FIELDS = %w[category1 category2 category3].freeze
7
+ # Written as plain decimals ("16.50"): the models store text as sent,
8
+ # and "1,599.99" or "16,50" would otherwise be saved as 1 or 16.
9
+ DECIMAL_FIELDS = %w[price compare_at_price cost_price weight height width depth carton_weight].freeze
7
10
 
8
11
  def initialize(row, **)
9
12
  super
13
+ ensure_decimals!
10
14
  @store = row.store
11
15
  @seller = import.seller
12
16
  # Before the product is saved: rows are not wrapped in a transaction,
@@ -83,6 +87,14 @@ module Spree
83
87
 
84
88
  private
85
89
 
90
+ def ensure_decimals!
91
+ DECIMAL_FIELDS.each do |column|
92
+ Spree::Money::Rounding.parse_decimal(attributes[column])
93
+ rescue Spree::Money::InvalidFormat
94
+ raise ArgumentError, I18n.t('spree.import_invalid_number', column: column, value: attributes[column].to_s.strip)
95
+ end
96
+ end
97
+
86
98
  # A product-header (options-empty) row creates an option-less default
87
99
  # variant to hold product-level attributes. Once an option-bearing
88
100
  # variant exists, that placeholder is a leftover — remove it and
@@ -16,15 +16,15 @@ module Spree
16
16
  # English, Brazilian vs. European Portuguese, Simplified vs. Traditional
17
17
  # Chinese, etc.).
18
18
  #
19
- # The underlying translation tables accept any locale string, so this list
20
- # governs only what the locale pickers offer — it is not a hard storage
21
- # constraint.
19
+ # Markets and stores only accept codes from this list when their locales
20
+ # change, so an order's or cart's locale (which must be one its store
21
+ # supports) is one too, unless the store kept a value saved before that.
22
22
  ALL = %w[
23
23
  aa ab ae af ak am an ar as av ay az
24
24
  ba be bg bh bi bm bn bo br bs
25
25
  ca ce ch co cr cs cu cv cy
26
26
  da de de-AT de-CH dv dz
27
- ee el en en-AU en-CA en-GB en-IN en-NZ eo es es-419 es-MX et eu
27
+ ee el en en-AU en-CA en-GB en-IN en-NZ en-US eo es es-419 es-CL es-EC es-MX et eu
28
28
  fa ff fi fj fo fr fr-CA fy
29
29
  ga gd gl gn gu gv
30
30
  ha he hi ho hr ht hu hy hz
@@ -38,7 +38,7 @@ module Spree
38
38
  pa pi pl ps pt pt-BR pt-PT
39
39
  qu
40
40
  rm rn ro ru rw
41
- sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw
41
+ sa sc sd se sg si sk sl sl-SI sm sn so sq sr ss st su sv sw
42
42
  ta te tg th ti tk tl tn to tr ts tt tw ty
43
43
  ug uk ur uz
44
44
  ve vi vo
@@ -47,5 +47,39 @@ module Spree
47
47
  yi yo
48
48
  za zh-CN zh-HK zh-TW zu
49
49
  ].freeze
50
+
51
+ # The codes in a comma-separated list that are not listed, for a
52
+ # validation to name.
53
+ #
54
+ # @param codes [String, nil] e.g. "en,pt-BR"
55
+ # @return [Array<String>]
56
+ def self.unlisted(codes)
57
+ codes.to_s.split(',').map(&:strip).reject(&:empty?) - ALL
58
+ end
59
+
60
+ # A listed locale for a code from elsewhere (a host app's I18n config, a
61
+ # country's official language, a legacy column): the listed spelling of
62
+ # the code itself (`en_US` and `pt-br` give `en-US` and `pt-BR`),
63
+ # otherwise the listed one for its language (`en-IE` gives `en`, `zh`
64
+ # gives `zh-CN`), or nil when Spree knows neither.
65
+ #
66
+ # @param code [String, Symbol, nil]
67
+ # @return [String, nil]
68
+ def self.listed(code)
69
+ code = code.to_s.strip.tr('_', '-')
70
+ return if code.empty?
71
+
72
+ ALL.find { |listed| listed.casecmp?(code) } || for_language(code.split('-').first)
73
+ end
74
+
75
+ # The listed locale for an ISO 639-1 language: the language itself, or
76
+ # its first regional variant when only those are listed (`zh` gives
77
+ # `zh-CN`).
78
+ #
79
+ # @param language [String]
80
+ # @return [String, nil]
81
+ def self.for_language(language)
82
+ Spree.locale_for_language(language.to_s.downcase, ALL)
83
+ end
50
84
  end
51
85
  end