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
@@ -45,7 +45,7 @@ module Spree
45
45
  rescue ActiveRecord::RecordInvalid => e
46
46
  failure(e.record, e.record.errors.full_messages.to_sentence)
47
47
  rescue ActiveStorage::FileNotFoundError
48
- # An abandoned direct upload leaves the blob row without its bytes.
48
+ # An abandoned presigned upload leaves the blob row without its bytes.
49
49
  # ActiveStorage raises a message-less error for that, which would
50
50
  # otherwise be an unhandled 500 on the Admin API.
51
51
  order ||= @store.orders.new
@@ -71,8 +71,8 @@ module Spree
71
71
  }
72
72
 
73
73
  # The buyer's purchase order, when it arrived with the paperwork. A
74
- # signed blob id, so it assigns like any other attribute.
75
- attrs[:po_document] = @params[:po_document] if @params[:po_document].present?
74
+ # signed id, so it assigns like any other attribute.
75
+ attrs[:po_document_signed_id] = @params[:po_document_signed_id] if @params[:po_document_signed_id].present?
76
76
 
77
77
  attrs[:market] = resolve_market if @params[:market_id].present?
78
78
  attrs[:channel] = resolve_channel if @params[:channel_id].present?
@@ -17,8 +17,8 @@ module Spree
17
17
  # @param line_item [Spree::LineItem, nil] nil distributes order-level
18
18
  # @return [Spree::ServiceModule::Result] value is the created rows
19
19
  def call(order:, label:, value:, value_type: 'flat', line_item: nil)
20
- value = BigDecimal(value.to_s)
21
- return failure(nil, I18n.t('spree.errors.messages.discount_value_must_be_positive')) unless value.positive?
20
+ value = Spree::Money::Rounding.parse_decimal(value)
21
+ return failure(nil, I18n.t('spree.errors.messages.discount_value_must_be_positive')) unless value&.positive?
22
22
  return failure(nil, I18n.t('spree.errors.messages.discount_value_type_invalid')) unless %w[flat percent].include?(value_type)
23
23
 
24
24
  rows = order.with_lock do
@@ -39,7 +39,7 @@ module Spree
39
39
 
40
40
  def line_item_row(order, line_item, label, value, value_type)
41
41
  base = discountable_base(line_item)
42
- amount = -[amount_for(base, value, value_type), base].min
42
+ amount = -[amount_for(base, value, value_type, order.currency), base].min
43
43
  return if amount.zero?
44
44
 
45
45
  create_row(order, line_item, label, amount, value, value_type)
@@ -51,19 +51,19 @@ module Spree
51
51
  bases_sum = bases.sum
52
52
  return [] if bases_sum <= 0
53
53
 
54
- total = [amount_for(bases_sum, value, value_type), bases_sum].min
55
- shares = Spree::Adjusters::LargestRemainder.largest_remainder_shares((total * 100).round, bases)
54
+ total = [amount_for(bases_sum, value, value_type, order.currency), bases_sum].min
55
+ shares = Spree::Adjusters::LargestRemainder.apportion(total, bases, order.currency)
56
56
 
57
57
  line_items.each_with_index.filter_map do |line_item, index|
58
- amount = -BigDecimal(shares[index]) / 100
58
+ amount = -shares[index]
59
59
  next if amount.zero?
60
60
 
61
61
  create_row(order, line_item, label, amount, value, value_type)
62
62
  end
63
63
  end
64
64
 
65
- def amount_for(base, value, value_type)
66
- value_type == 'percent' ? (base * value / 100).round(2) : value
65
+ def amount_for(base, value, value_type, currency)
66
+ Spree::Money::Rounding.to_currency(value_type == 'percent' ? base * value / 100 : value, currency)
67
67
  end
68
68
 
69
69
  # Remaining discountable base: amount net of already-applied discounts.
@@ -44,9 +44,12 @@ module Spree
44
44
  # (docs/plans/6.0-volume-pricing.md).
45
45
  def call(rows:)
46
46
  rows = Array(rows).map { |r| r.with_indifferent_access }
47
- # Amounts are parsed once, here; everything below reads numbers.
48
47
  keyed = rows.select { |r| r[:variant_id].present? && r[:currency].present? }
49
- .map { |r| r.merge(amount: parse_amount(r[:amount]), compare_at_amount: parse_amount(r[:compare_at_amount])) }
48
+ unreadable = rows_with_malformed_amount(keyed)
49
+ return failure(nil, malformed_amounts: unreadable) if unreadable.any?
50
+
51
+ # Amounts are parsed once, here; everything below reads numbers.
52
+ keyed = keyed.map { |r| r.merge(amount: Spree::Money::Rounding.parse_decimal(r[:amount]), compare_at_amount: Spree::Money::Rounding.parse_decimal(r[:compare_at_amount])) }
50
53
  # Checked before anything is deduped: `row_key` coerces the quantity,
51
54
  # so two malformed rows would collapse into one and the batch would be
52
55
  # judged on a shape the caller never sent.
@@ -98,12 +101,13 @@ module Spree
98
101
  upsert_batch(base_rows, BASE_UNIQUE_BY)
99
102
  end
100
103
  upsert_batch(override_rows, OVERRIDE_UNIQUE_BY)
101
- sweep(affected_keys, clear_rows)
104
+ cleared_variant_ids = sweep(affected_keys, clear_rows)
102
105
  # `upsert_all` and `delete_all` both skip AR callbacks, so the
103
106
  # `Price -> Variant -> Product` `touch:` chain never fires —
104
107
  # downstream caches (`cache_key_with_version`) would stay stale.
105
- # Re-trigger the chain with one `.touch` per affected variant.
106
- touch_variants(affected_keys.map { |k| k[0] }.uniq)
108
+ # A blank row clearing a rung that had no amount changes nothing,
109
+ # so only priced rows and cleared amounts touch their variant.
110
+ touch_variants((upsert_rows.map { |r| r[:variant_id].to_s } + cleared_variant_ids).uniq)
107
111
  end
108
112
 
109
113
  success(price_count: payload.length)
@@ -167,6 +171,18 @@ module Spree
167
171
  Integer(raw.to_s.strip, 10, exception: false)
168
172
  end
169
173
 
174
+ # Rows whose amount is not a number or plain decimal text ("1,599.99",
175
+ # `false`), refused before a malformed value can read as "clear".
176
+ def rows_with_malformed_amount(rows)
177
+ rows.each_with_index.filter_map do |row, index|
178
+ Spree::Money::Rounding.parse_decimal(row[:amount])
179
+ Spree::Money::Rounding.parse_decimal(row[:compare_at_amount])
180
+ nil
181
+ rescue Spree::Money::InvalidFormat
182
+ { index: index }
183
+ end
184
+ end
185
+
170
186
  # Rows pricing below zero. This path runs no model validations, so the
171
187
  # sign is checked here for every caller rather than in each of them.
172
188
  def rows_with_negative_amount(rows)
@@ -370,15 +386,6 @@ module Spree
370
386
  [row[:variant_id].to_s, row[:currency], row[:price_list_id].to_s]
371
387
  end
372
388
 
373
- # Parses locale-aware decimal input ("1.234,56" in DE, "1,234.56"
374
- # in en-US). Numeric values pass through; blank values become nil.
375
- def parse_amount(value)
376
- return nil if value.blank?
377
- return value if value.is_a?(Numeric)
378
-
379
- Spree::LocalizedNumber.parse(value)
380
- end
381
-
382
389
  def sweep(affected_keys, clear_rows)
383
390
  cleared_keys = clear_rows.map { |r| row_key(r) }.to_set
384
391
  affected_set = affected_keys.to_set
@@ -392,16 +399,14 @@ module Spree
392
399
  )
393
400
  .pluck(:id, :variant_id, :currency, :price_list_id, :min_quantity, :amount)
394
401
 
395
- doomed_ids = candidates.filter_map do |id, variant_id, currency, price_list_id, min_quantity, amount|
402
+ doomed = candidates.select do |_, variant_id, currency, price_list_id, min_quantity, amount|
396
403
  key = [variant_id.to_s, currency, price_list_id&.to_s, min_quantity]
397
- next unless affected_set.include?(key)
398
- next id if amount.nil?
399
- next id if cleared_keys.include?(key)
400
-
401
- nil
404
+ affected_set.include?(key) && (amount.nil? || cleared_keys.include?(key))
402
405
  end
403
406
 
404
- Spree::Price.where(id: doomed_ids).delete_all if doomed_ids.any?
407
+ Spree::Price.where(id: doomed.map(&:first)).delete_all if doomed.any?
408
+
409
+ doomed.filter_map { |_, variant_id, *, amount| variant_id.to_s if amount }
405
410
  end
406
411
 
407
412
  # Bumps `updated_at` on the affected variants and their parent
@@ -410,15 +415,17 @@ module Spree
410
415
  # `Variant -> Product` `touch:` chains otherwise.
411
416
  #
412
417
  # We use `touch_all` rather than per-record `Variant#touch`: it bumps
413
- # every affected row in a single UPDATE and skips AR callbacks, giving
414
- # the cache bust without extra side effects.
418
+ # every affected row in a single UPDATE. It also skips the product's
419
+ # commit callbacks, so the search index, which stores prices, is
420
+ # refreshed here.
415
421
  def touch_variants(variant_ids)
416
422
  return if variant_ids.empty?
417
423
 
418
424
  variants = Spree::Variant.where(id: variant_ids)
419
- product_ids = variants.pluck(:product_id).uniq
425
+ products = Spree::Product.where(id: variants.select(:product_id))
420
426
  variants.touch_all
421
- Spree::Product.where(id: product_ids).touch_all if product_ids.any?
427
+ products.touch_all
428
+ products.find_each(&:enqueue_search_index)
422
429
  end
423
430
 
424
431
  # MySQL infers conflict targets from its own unique indexes and
@@ -14,7 +14,7 @@ module Spree
14
14
  s.customer_support_email = 'support@example.com'
15
15
  s.default_currency = 'USD'
16
16
  s.default_country_code = 'US'
17
- s.default_locale = I18n.locale
17
+ s.default_locale = Spree::Locales.listed(I18n.locale) || 'en'
18
18
  end.save!
19
19
  end
20
20
  end
@@ -18,8 +18,9 @@ module Spree
18
18
 
19
19
  # @param store [Spree::Store] the store to provision, already persisted
20
20
  # @param country [Spree::Country] where the shop ships from
21
- # @param locale [String, nil] storefront locale; defaults to the
22
- # country's own language when Spree translates it, then English
21
+ # @param locale [String, nil] storefront locale, a code from
22
+ # `Spree::Locales::ALL` (another spelling or region moves to the listed
23
+ # one); defaults to the country's own language, then English
23
24
  # @param currency [String, nil] ISO 4217 code; defaults to the country's
24
25
  # own currency. Merchants who ship from one country and price in
25
26
  # another currency pass it explicitly.
@@ -55,21 +56,17 @@ module Spree
55
56
  found&.iso_code || country.default_currency.presence || 'USD'
56
57
  end
57
58
 
58
- # An explicit locale is honored as given — a host app may ship its own
59
- # translations. Only the *derived* default is filtered: falling back to
60
- # a country's official language that Spree has no strings for would set
61
- # a storefront to a language with nothing behind it, and it is not one
62
- # the setup screen ever offers.
59
+ # A store's locale is a content locale, so it comes from
60
+ # `Spree::Locales::ALL` whether or not Spree ships translations for it:
61
+ # the storefront and dashboard carry their own. An explicit locale is
62
+ # kept when it is listed, or moved to its listed language (`en-IE`
63
+ # becomes `en`); one Spree cannot place, or none, gives the country's
64
+ # first official language Spree lists.
63
65
  def resolve_locale(requested, country)
64
- return requested if requested.present?
66
+ listed = Spree::Locales.listed(requested)
67
+ return listed if listed
65
68
 
66
- derived = country.default_locale.presence
67
- return 'en' if derived.blank?
68
-
69
- locales = Spree.available_locales
70
- Spree.locale_for_language(derived, locales) ||
71
- country.official_locales.lazy.filter_map { |language| Spree.locale_for_language(language, locales) }.first ||
72
- 'en'
69
+ [country.default_locale, *country.official_locales].lazy.filter_map { |language| Spree::Locales.for_language(language) }.first || 'en'
73
70
  end
74
71
 
75
72
  # The store's own country/currency/locale columns are not the source of
@@ -260,7 +257,7 @@ module Spree
260
257
  calculator = delivery_method.calculator
261
258
  next unless calculator.respond_to?(:preferred_currency)
262
259
  next if calculator.preferred_currency == currency
263
- next unless calculator.preferences[:amount].to_f.zero?
260
+ next unless Spree::Money::Rounding.blank_or_zero?(calculator.preferences[:amount])
264
261
 
265
262
  calculator.update!(preferences: calculator.preferences.merge(currency: currency))
266
263
  end
@@ -0,0 +1,160 @@
1
+ require 'net/http'
2
+
3
+ module Spree
4
+ # Asks spreecloud.io which Spree releases are newer than the running one,
5
+ # and reports usage with the same request. The answer is cached for a day,
6
+ # so at most one admin request a day waits on it, for no more than the two
7
+ # one-second timeouts. A failed check is cached for an hour, so a passing
8
+ # outage costs one slow request an hour and never hides a release for long.
9
+ #
10
+ # The +version+, +environment+, +url+ and +install_id+ query keys are what
11
+ # Spree 5 sent, and the endpoint still serves 5.x installs with them. They
12
+ # are never renamed; new keys only ever go beside them.
13
+ class UpdateCheck
14
+ ENDPOINT = 'https://spreecloud.io/updates.json'.freeze
15
+ RELEASES_URL = 'https://github.com/spree/spree/releases/tag/'.freeze
16
+ RELEASE_TAG = /\Av\d+(\.[0-9a-z]+)*\z/
17
+ CACHE_TTL = 1.day
18
+ FAILURE_TTL = 1.hour
19
+ OPEN_TIMEOUT = 1
20
+ READ_TIMEOUT = 1
21
+ # The read timeout applies to each read, not the whole response, so a
22
+ # slowly trickling answer needs an overall deadline as well.
23
+ TOTAL_TIMEOUT = 2
24
+ # Order-of-magnitude buckets: enough to tell a hobby install from a large
25
+ # merchant, never an exact figure.
26
+ BUCKETS = {
27
+ 0..0 => '0',
28
+ 1..9 => '1-9',
29
+ 10..99 => '10-99',
30
+ 100..999 => '100-999',
31
+ 1_000..9_999 => '1k-9k',
32
+ 10_000..99_999 => '10k-99k'
33
+ }.freeze
34
+ LARGEST_BUCKET = '100k+'.freeze
35
+
36
+ class << self
37
+ # @return [Boolean] false when the operator turned the check off
38
+ def enabled?
39
+ Spree::Config.update_check_enabled && !env_flag?('SPREE_UPDATE_CHECK_DISABLED')
40
+ end
41
+
42
+ # When false, the check still runs but sends the running version only.
43
+ #
44
+ # @return [Boolean]
45
+ def telemetry_enabled?
46
+ Spree::Config.telemetry_enabled &&
47
+ !env_flag?('SPREE_TELEMETRY_DISABLED') &&
48
+ !env_flag?('DO_NOT_TRACK')
49
+ end
50
+
51
+ # The cached answer, fetched from the endpoint when there is none.
52
+ #
53
+ # @return [Spree::UpdateStatus]
54
+ def status
55
+ return Spree::UpdateStatus.new(current_version: Spree.version, enabled: false) unless enabled?
56
+
57
+ result = Rails.cache.read(cache_key) || refresh
58
+
59
+ Spree::UpdateStatus.new(current_version: Spree.version, checked_at: result[:checked_at], releases: result[:releases])
60
+ end
61
+
62
+ # @return [Hash] the query string the endpoint receives
63
+ def query
64
+ return { version: Spree.version } unless telemetry_enabled?
65
+
66
+ {
67
+ version: Spree.version,
68
+ environment: Rails.env,
69
+ url: Spree::Store.default&.storefront_url,
70
+ install_id: Spree.install_id,
71
+ ruby: RUBY_VERSION,
72
+ rails: Rails.version,
73
+ database: ActiveRecord::Base.connection_db_config.adapter,
74
+ gems: Gem.loaded_specs.keys.grep(/\Aspree_/).sort.join(','),
75
+ dashboard: dashboard_served?,
76
+ stores: bucket(Spree::Store.count),
77
+ products: bucket(Spree::Product.count),
78
+ orders: bucket(Spree::Order.complete.count),
79
+ sellers: seller_bucket
80
+ }.compact
81
+ end
82
+
83
+ private
84
+
85
+ def refresh
86
+ releases = fetch_releases
87
+ result = { releases: releases || [], checked_at: Time.current }
88
+ Rails.cache.write(cache_key, result, expires_in: releases ? CACHE_TTL : FAILURE_TTL)
89
+ result
90
+ end
91
+
92
+ # Nil when the check failed, for whatever reason: an update notice is
93
+ # never worth failing the admin's request over, including a database
94
+ # error while counting for the usage fields.
95
+ def fetch_releases
96
+ uri = URI(ENDPOINT)
97
+ uri.query = URI.encode_www_form(query)
98
+
99
+ response = Timeout.timeout(TOTAL_TIMEOUT) do
100
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, max_retries: 0,
101
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
102
+ http.get(uri.request_uri)
103
+ end
104
+ end
105
+ return unless response.is_a?(Net::HTTPSuccess)
106
+
107
+ newer_releases(JSON.parse(response.body))
108
+ rescue StandardError => e
109
+ Rails.error.report(e, handled: true, severity: :warning, source: 'spree.update_check')
110
+ nil
111
+ end
112
+
113
+ # The endpoint already filters by version; this does it again and
114
+ # builds each link from the tag, so whatever comes back over the wire
115
+ # never reaches the dashboard as a URL. Release candidates are only
116
+ # offered to installs already running one.
117
+ def newer_releases(payload)
118
+ current = Gem::Version.new(Spree.version)
119
+
120
+ Array(payload).filter_map { |release|
121
+ tag = release.is_a?(Hash) ? release['tag_name'].to_s : ''
122
+ next unless tag.match?(RELEASE_TAG)
123
+
124
+ version = Gem::Version.new(tag.delete_prefix('v'))
125
+ next unless version > current
126
+ next if version.prerelease? && !current.prerelease?
127
+
128
+ { version: version.to_s, url: "#{RELEASES_URL}#{tag}", published_at: parse_time(release['published_at']) }
129
+ }.sort_by { |release| Gem::Version.new(release[:version]) }.reverse
130
+ end
131
+
132
+ def parse_time(value)
133
+ Time.iso8601(value.to_s)
134
+ rescue ArgumentError
135
+ nil
136
+ end
137
+
138
+ def bucket(count)
139
+ BUCKETS.find { |range, _| range.cover?(count) }&.last || LARGEST_BUCKET
140
+ end
141
+
142
+ def seller_bucket
143
+ count = Spree::Seller.count
144
+ bucket(count) if count.positive?
145
+ end
146
+
147
+ def dashboard_served?
148
+ (defined?(Spree::Dashboard) && Spree::Dashboard.dist_path.present?) || Spree::Config.dashboard_url.present?
149
+ end
150
+
151
+ def env_flag?(name)
152
+ ActiveModel::Type::Boolean.new.cast(ENV[name].presence) == true
153
+ end
154
+
155
+ def cache_key
156
+ "spree/update_check/#{Spree.version}"
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,92 @@
1
+ module Spree
2
+ module Uploads
3
+ # Gives every attached file that has no store the store of the record it
4
+ # is attached to (the 6.0 upgrade, `spree:uploads:assign_stores`).
5
+ #
6
+ # Owners with a `store_id` column are handled with one set-based update per
7
+ # attachment type; the rest (a variant's digital file, a seller's
8
+ # submission) ask {Spree::Uploads.store_id_for} record by record. Files
9
+ # attached nowhere keep no store, and the daily purge leaves them alone.
10
+ #
11
+ # Safe to re-run: only files without a store are touched.
12
+ class AssignStores
13
+ prepend Spree::ServiceModule::Base
14
+
15
+ BATCH_SIZE = 1_000
16
+
17
+ # @return [Spree::ServiceModule::Result] a hash with `assigned`, the
18
+ # number of files given a store, and `conflicts`, the ids of files
19
+ # attached to records of more than one store, which are reported and
20
+ # left for a person to resolve
21
+ def call
22
+ assigned = record_types.sum do |record_type|
23
+ owner_class = record_type.safe_constantize
24
+ next 0 if owner_class.nil?
25
+
26
+ # Renditions and previews are blobs themselves: a set-based update
27
+ # would join the table it updates, which MySQL refuses.
28
+ if owner_class.column_names.include?('store_id') && !record_type.start_with?('ActiveStorage::')
29
+ assign_from_store_column(record_type, owner_class)
30
+ else
31
+ assign_record_by_record(record_type)
32
+ end
33
+ end
34
+
35
+ success(assigned: assigned, conflicts: conflicting_blob_ids)
36
+ end
37
+
38
+ private
39
+
40
+ # Renditions and previews take their store from their original file, so
41
+ # they go last, once the originals have one.
42
+ def record_types
43
+ ActiveStorage::Attachment.distinct.pluck(:record_type).
44
+ sort_by { |record_type| [record_type.start_with?('ActiveStorage::') ? 1 : 0, record_type] }
45
+ end
46
+
47
+ # Correlated subqueries rather than UPDATE … FROM, which MySQL and SQLite
48
+ # spell differently. Several attachments of one file in different stores
49
+ # pick one; {#conflicting_blob_ids} reports them.
50
+ def assign_from_store_column(record_type, owner_class)
51
+ attachments_table = ActiveStorage::Attachment.table_name
52
+ owners_table = owner_class.table_name
53
+ owner_store = ActiveStorage::Attachment.
54
+ joins("INNER JOIN #{owners_table} ON #{owners_table}.id = #{attachments_table}.record_id").
55
+ where(record_type: record_type).
56
+ where("#{attachments_table}.blob_id = #{ActiveStorage::Blob.table_name}.id").
57
+ where.not(owners_table => { store_id: nil })
58
+
59
+ ActiveStorage::Blob.where(store_id: nil).
60
+ where(id: ActiveStorage::Attachment.where(record_type: record_type).select(:blob_id)).
61
+ where(owner_store.arel.exists).
62
+ update_all(["store_id = (#{owner_store.select("#{owners_table}.store_id").limit(1).to_sql})"])
63
+ end
64
+
65
+ # One update per store and batch rather than per file.
66
+ def assign_record_by_record(record_type)
67
+ unassigned = ActiveStorage::Attachment.joins(:blob).
68
+ where(record_type: record_type, ActiveStorage::Blob.table_name => { store_id: nil })
69
+
70
+ unassigned.preload(:record).in_batches(of: BATCH_SIZE).sum do |batch|
71
+ batch.select(&:record).group_by { |attachment| Spree::Uploads.store_id_for(attachment.record) }.sum do |store_id, owned|
72
+ next 0 if store_id.nil?
73
+
74
+ ActiveStorage::Blob.where(id: owned.map(&:blob_id), store_id: nil).update_all(store_id: store_id)
75
+ end
76
+ end
77
+ end
78
+
79
+ def conflicting_blob_ids
80
+ resolvable_types = record_types.select(&:safe_constantize)
81
+ shared_blob_ids = ActiveStorage::Attachment.where(record_type: resolvable_types).group(:blob_id).having('COUNT(*) > 1').pluck(:blob_id)
82
+
83
+ shared_blob_ids.each_slice(BATCH_SIZE).flat_map do |blob_ids|
84
+ ActiveStorage::Attachment.where(blob_id: blob_ids, record_type: resolvable_types).includes(:record).group_by(&:blob_id).filter_map do |blob_id, blob_attachments|
85
+ store_ids = blob_attachments.filter_map { |attachment| attachment.record && Spree::Uploads.store_id_for(attachment.record) }
86
+ blob_id if store_ids.map(&:to_s).uniq.size > 1
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -44,6 +44,16 @@ module Spree
44
44
  # identified outright and never reaches the tie-break at all.
45
45
  CONTAINER_CONTENT_TYPES = %w[application/zip application/x-ole-storage].freeze
46
46
 
47
+ # Plain-text formats carry no signature at all, so the bytes can only prove
48
+ # what they are not. Marcel names anything it recognises — HTML, XML, SVG,
49
+ # scripts with a shebang, executables — and those never reach this
50
+ # tie-break. What is left is accepted under a text extension only when it
51
+ # is free of the control bytes every binary format carries. No particular
52
+ # encoding is required, since spreadsheet tools still export in legacy ones.
53
+ TEXT_CONTENT_TYPES = %w[text/csv].freeze
54
+ UNIDENTIFIED_CONTENT_TYPES = %w[application/octet-stream text/plain].freeze
55
+ BINARY_BYTES = /[\x00-\x08\x0B\x0E-\x1F\x7F]/n
56
+
47
57
  # A misspelled or forgotten option would otherwise leave this validator
48
58
  # silently accepting everything, on the one path whose whole purpose is
49
59
  # refusing hostile files. An allowlist that resolves to empty at runtime is
@@ -116,9 +126,16 @@ module Spree
116
126
  # read, which would hand the decision back to whoever uploaded the file.
117
127
  detected = Marcel::MimeType.for(StringIO.new(head))
118
128
  return true if permitted.include?(detected)
129
+ return permitted_plain_text?(blob, head, permitted) if UNIDENTIFIED_CONTENT_TYPES.include?(detected)
119
130
  return false unless CONTAINER_CONTENT_TYPES.include?(detected)
120
131
 
121
- permitted.include?(office_type_from_extension(blob))
132
+ permitted.include?(type_from_extension(blob))
133
+ end
134
+
135
+ def permitted_plain_text?(blob, head, permitted)
136
+ text_type = type_from_extension(blob)
137
+
138
+ TEXT_CONTENT_TYPES.include?(text_type) && permitted.include?(text_type) && !head.b.match?(BINARY_BYTES)
122
139
  end
123
140
 
124
141
  # Asks the extension alone, rather than re-reading the bytes with a filename
@@ -129,7 +146,7 @@ module Spree
129
146
  # bytes we read.
130
147
  #
131
148
  # @return [String, nil]
132
- def office_type_from_extension(blob)
149
+ def type_from_extension(blob)
133
150
  filename = blob.filename.to_s
134
151
  return if File.extname(filename).blank?
135
152
 
@@ -0,0 +1,22 @@
1
+ module Spree
2
+ # Rejects locale codes `Spree::Locales::ALL` does not list, in a single code
3
+ # or a comma-separated list, naming the ones it refuses. Reads the stored
4
+ # column, since a store answers `default_locale` from its default market,
5
+ # and checks only an attribute that is changing, so a record saved before
6
+ # the list was enforced can still be edited.
7
+ #
8
+ # @example
9
+ # validates_with Spree::LocaleCodesValidator, attributes: %i[default_locale supported_locales]
10
+ class LocaleCodesValidator < ActiveModel::Validator
11
+ def validate(record)
12
+ Array(options[:attributes]).each do |attribute|
13
+ next unless record.will_save_change_to_attribute?(attribute)
14
+
15
+ unlisted = Spree::Locales.unlisted(record.read_attribute(attribute))
16
+ next if unlisted.empty?
17
+
18
+ record.errors.add(attribute, I18n.t('spree.locales_not_listed', codes: unlisted.join(', ')))
19
+ end
20
+ end
21
+ end
22
+ end
@@ -320,8 +320,8 @@ module Spree
320
320
  end
321
321
 
322
322
  # Strict decimal parsing (same semantics as Shipment#cost=, which the
323
- # update_columns freeze path bypasses) — the lenient LocalizedNumber
324
- # would turn garbage into 0, and 0 is a legal cost here.
323
+ # update_columns freeze path bypasses) — a lenient parse would turn
324
+ # garbage into 0, and 0 is a legal cost here.
325
325
  #
326
326
  # @return [BigDecimal, Numeric, nil] nil when no cost was given (blank
327
327
  # counts as omitted); a failure Result for malformed or negative input
@@ -14,11 +14,13 @@ module Spree
14
14
  hooks :validate, :before_capture, :after_capture
15
15
 
16
16
  # @param payment [Spree::Payment] a completed payment is a no-op
17
- # @param amount [Integer, nil] amount in cents; nil captures in full,
18
- # a smaller amount splits the remainder into a new pending payment
17
+ # @param amount [BigDecimal, nil] in the currency's own units; nil
18
+ # captures in full, a smaller amount splits the remainder into a new
19
+ # pending payment. An Integer is read as hundredths of the currency,
20
+ # the pre-6.0 contract (deprecated, removed in 6.1).
19
21
  def perform(payment:, amount: nil)
20
22
  super
21
- @amount = amount || payment.money.amount_in_cents
23
+ @amount = capture_amount(amount)
22
24
 
23
25
  step :ensure_capturable
24
26
  # Veto point — fraud holds, manual review. Before any money moves.
@@ -44,6 +46,17 @@ module Spree
44
46
 
45
47
  attr_reader :response, :remainder
46
48
 
49
+ def capture_amount(amount)
50
+ return payment.amount if amount.nil?
51
+ return Spree::Money::Rounding.to_currency(amount, payment.currency) unless amount.is_a?(Integer)
52
+
53
+ Spree::Deprecation.warn(
54
+ 'Passing an Integer amount to the payment capture workflow is deprecated and is read as hundredths of ' \
55
+ 'the currency. Pass the amount in the currency\'s own units as a BigDecimal; removed in Spree 6.1.'
56
+ )
57
+ Spree::Money::Rounding.from_hundredths(amount)
58
+ end
59
+
47
60
  # An already-captured payment returns success rather than failing:
48
61
  # capture is naturally idempotent and double-submitted admin actions
49
62
  # must not surface as errors. A failed payment is capturable — the
@@ -65,7 +78,9 @@ module Spree
65
78
  def capture_at_gateway
66
79
  @response = payment.protect_from_connection_error do
67
80
  instrument_gateway_call(:capture, payment.payment_method) do
68
- payment.payment_method.capture(@amount, payment.response_code, payment.gateway_options)
81
+ payment.payment_method.capture(
82
+ payment.payment_method.gateway_amount(@amount, payment.currency), payment.response_code, payment.gateway_options
83
+ )
69
84
  end
70
85
  end
71
86
 
@@ -86,7 +101,7 @@ module Spree
86
101
  if Spree::Payment.where(id: payment.id, status: 'completed').exists?
87
102
  already_captured = true
88
103
  else
89
- payment.capture_events.create!(amount: ::Money.new(@amount, payment.currency).to_f)
104
+ payment.capture_events.create!(amount: @amount)
90
105
  # Split before completing, so payment.completed publishes with
91
106
  # the captured amount and the order recomputes from correct rows.
92
107
  @remainder = payment.split_uncaptured_amount
@@ -57,7 +57,9 @@ module Spree
57
57
 
58
58
  response = payment.protect_from_connection_error do
59
59
  instrument_gateway_call(verb, payment.payment_method) do
60
- payment.payment_method.public_send(verb, payment.money.amount_in_cents, payment.source, payment.gateway_options)
60
+ payment.payment_method.public_send(
61
+ verb, payment.payment_method.gateway_amount(payment.amount, payment.currency), payment.source, payment.gateway_options
62
+ )
61
63
  end
62
64
  end
63
65
 
@@ -61,7 +61,9 @@ module Spree
61
61
  unless result.success?
62
62
  refusal = result.error&.value
63
63
  refusal = {} unless refusal.is_a?(Hash)
64
- if refusal[:invalid_amounts].present?
64
+ if refusal[:malformed_amounts].present?
65
+ price_list.errors.add(:base, :malformed_price)
66
+ elsif refusal[:invalid_amounts].present?
65
67
  price_list.errors.add(:base, :negative_price)
66
68
  elsif refusal[:invalid_quantities].present?
67
69
  price_list.errors.add(:base, :invalid_quantity)
@@ -76,12 +78,10 @@ module Spree
76
78
  end
77
79
  return failure(price_list)
78
80
  end
79
-
80
- touch_variants(rows.map { |row| row[:variant_id] }.uniq)
81
81
  end
82
82
 
83
83
  def clear_prices
84
- variant_ids = price_list.prices.distinct.pluck(:variant_id)
84
+ variant_ids = price_list.prices.where.not(amount: nil).distinct.pluck(:variant_id)
85
85
  price_list.prices.update_all(amount: nil, compare_at_amount: nil, updated_at: Time.current)
86
86
  touch_variants(variant_ids)
87
87
  end