spree_core 6.0.0 → 6.0.2

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
  5. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
  6. data/app/models/concerns/spree/admin_user_methods.rb +23 -0
  7. data/app/models/concerns/spree/translatable_resource.rb +19 -0
  8. data/app/models/concerns/spree/user_roles.rb +9 -4
  9. data/app/models/spree/invitation.rb +11 -2
  10. data/app/models/spree/order.rb +3 -1
  11. data/app/models/spree/order_promotion.rb +3 -1
  12. data/app/models/spree/order_routing/strategy/rules.rb +8 -48
  13. data/app/models/spree/payment.rb +16 -1
  14. data/app/models/spree/payment_method/store_credit.rb +3 -1
  15. data/app/models/spree/payment_method.rb +14 -1
  16. data/app/models/spree/payment_source.rb +3 -1
  17. data/app/models/spree/promotion/actions/create_item_adjustments.rb +1 -1
  18. data/app/models/spree/promotion.rb +10 -1
  19. data/app/models/spree/promotion_handler/cart.rb +7 -1
  20. data/app/models/spree/promotion_handler/coupon.rb +1 -29
  21. data/app/models/spree/refresh_token.rb +26 -0
  22. data/app/models/spree/seller_transfer.rb +51 -0
  23. data/app/models/spree/stock/coordinator.rb +37 -17
  24. data/app/models/spree/stock/packer.rb +19 -9
  25. data/app/models/spree/stock/prioritizer.rb +30 -15
  26. data/app/models/spree/store.rb +35 -2
  27. data/app/models/spree/variant.rb +1 -1
  28. data/app/services/spree/carts/update.rb +2 -1
  29. data/app/services/spree/promotions/detach_from_owner.rb +72 -0
  30. data/app/services/spree/stock_reservations/reserve.rb +78 -26
  31. data/app/workflows/spree/carts/recalculate.rb +5 -1
  32. data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
  33. data/db/migrate/20260809150001_promote_shipping_categories_to_delivery_profiles.rb +15 -6
  34. data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
  35. data/db/migrate/20261030100001_scope_payment_sources_profile_id_uniqueness_to_payment_method.rb +38 -0
  36. data/db/migrate/20261031000001_add_last_active_at_to_spree_admin_users.rb +7 -0
  37. data/db/migrate/20261031100001_delete_orphaned_spree_option_value_variants.rb +10 -0
  38. data/lib/generators/spree/dummy/dummy_generator.rb +0 -1
  39. data/lib/generators/spree/dummy/templates/rails/database.yml +6 -9
  40. data/lib/generators/spree/dummy/templates/rails/test.rb +9 -5
  41. data/lib/generators/spree/dummy_model/templates/migration.rb.tt +1 -1
  42. data/lib/spree/checkout/default_requirements.rb +33 -5
  43. data/lib/spree/core/version.rb +1 -1
  44. data/lib/spree/core.rb +1 -3
  45. data/lib/spree/testing_support/common_rake.rb +3 -2
  46. data/lib/spree/testing_support/lifecycle_events.rb +1 -3
  47. data/lib/spree/time_zones.rb +34 -0
  48. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +15 -1
  49. data/lib/tasks/delivery_migration.rake +17 -1
  50. data/lib/tasks/remove_orphan_order_promotions.rake +20 -0
  51. metadata +15 -78
  52. data/lib/generators/spree/authentication/dummy/dummy_generator.rb +0 -54
  53. data/lib/generators/spree/authentication/dummy/templates/authentication_helpers.rb.tt +0 -52
  54. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -33
@@ -202,34 +202,15 @@ module Spree
202
202
  kind: 'refund_reversal',
203
203
  provider: @earning.provider,
204
204
  status: 'pending'
205
- }.merge(settlement_of(bounded))
205
+ }.merge(@earning.reversal_settlement(bounded))
206
206
  )
207
207
  end
208
208
  end
209
209
 
210
- # A clawback has to settle where its earning settled. Payouts are swept by
211
- # settlement currency, so a reversal left in the sale's currency would
212
- # never join the batch that pays the earning it cancels — the seller would
213
- # be paid in full for goods that came back, and the row would sit in a
214
- # currency their account cannot pay.
215
- #
216
- # Prorated from the earning's own settled figure rather than converted at
217
- # today's rate, so the money comes back at the rate it went out at.
218
- def settlement_of(bounded)
219
- return {} if @earning.settled_amount.blank? || @earning.amount.zero?
220
-
221
- share = @earning.settled_amount * (bounded / @earning.amount)
222
-
223
- {
224
- settled_amount: -Spree::Money::Rounding.quantize(
225
- share, Spree::Money::Rounding.precision(@earning.settlement_currency)
226
- ),
227
- settled_currency: @earning.settled_currency
228
- }
229
- end
230
-
210
+ # Asked of the provider that made the earning — see
211
+ # {Spree::SellerTransfer#payout_provider_instance}.
231
212
  def execute_reversal
232
- provider.reverse!(reversal)
213
+ @earning.payout_provider_instance.reverse!(reversal)
233
214
  rescue Spree::Core::AmbiguousGatewayError => e
234
215
  # Whether the clawback happened is the provider's to say. Recorded as
235
216
  # such rather than as a refusal, so an operator reconciling knows which
@@ -238,31 +219,11 @@ module Spree
238
219
  Rails.error.report(e, handled: true, context: { seller_transfer_id: reversal.id }, source: 'spree.core')
239
220
  failure(reversal, e.message)
240
221
  rescue StandardError => e
222
+ # Left retryable: `ExecutePendingJob` asks the provider again.
241
223
  reversal.update!(status: 'processing')
242
224
  Rails.error.report(e, handled: true, context: { seller_transfer_id: reversal.id }, source: 'spree.core')
243
225
  failure(reversal, e.message)
244
226
  end
245
-
246
- # The provider that made the earning, not whichever one the store uses
247
- # now. A marketplace that changes provider still has money sitting with
248
- # the old one, and asking the new one to reverse a transfer it never
249
- # made leaves the original standing — the seller keeps a refunded sale.
250
- def provider
251
- @provider ||= begin
252
- configured = Spree.payout_providers.find { |candidate| candidate.to_s == @earning.provider }
253
-
254
- # No silent fallback to whatever the store uses now. A provider that
255
- # is no longer installed still holds the transfer this reverses, and
256
- # handing the job to a different one would mark the row reversed
257
- # while the money stayed where it was. An operator has to know.
258
- if configured.nil?
259
- raise Spree::Core::GatewayError,
260
- "Payout provider #{@earning.provider} is not registered, so its transfer cannot be reversed"
261
- end
262
-
263
- configured.new
264
- end
265
- end
266
227
  end
267
228
  end
268
229
  end
@@ -52,6 +52,7 @@ class PromoteShippingCategoriesToDeliveryProfiles < ActiveRecord::Migration[8.1]
52
52
  remove_column :spree_product_types, :fulfillment_types
53
53
 
54
54
  backfill_store_defaults
55
+ copy_first_zone_onto_methods
55
56
 
56
57
  drop_table :spree_delivery_method_zones
57
58
  end
@@ -141,13 +142,21 @@ class PromoteShippingCategoriesToDeliveryProfiles < ActiveRecord::Migration[8.1]
141
142
  # never carried a category, so they all belong to the default profile.
142
143
  MigrationProduct.where(store_id: store.id, delivery_profile_id: nil).update_all(delivery_profile_id: profile.id)
143
144
 
144
- MigrationMethod.where(store_id: store.id).find_each do |method|
145
- zone_ids = MigrationMethodZone.where(delivery_method_id: method.id).order(:id).pluck(:delivery_zone_id)
146
- if zone_ids.size > 1
147
- say "Delivery method #{method.id} was linked to #{zone_ids.size} zones; keeping the first — recreate the method for the other zones if both were intended."
148
- end
149
- method.update_columns(delivery_profile_id: profile.id, delivery_origin_group_id: origin_group.id, delivery_zone_id: zone_ids.first)
145
+ MigrationMethod.where(store_id: store.id).update_all(delivery_profile_id: profile.id, delivery_origin_group_id: origin_group.id)
146
+ end
147
+ end
148
+
149
+ # Runs for every method, including 5.x rows whose store_id is still nil: the
150
+ # link table is dropped right after, so this is the last chance to keep it.
151
+ def copy_first_zone_onto_methods
152
+ zone_ids_by_method = MigrationMethodZone.order(:id).pluck(:delivery_method_id, :delivery_zone_id).
153
+ group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
154
+
155
+ zone_ids_by_method.each do |method_id, zone_ids|
156
+ if zone_ids.size > 1
157
+ say "Delivery method #{method_id} was linked to #{zone_ids.size} zones; keeping the first — recreate the method for the other zones if both were intended."
150
158
  end
159
+ MigrationMethod.where(id: method_id).update_all(delivery_zone_id: zone_ids.first)
151
160
  end
152
161
  end
153
162
  end
@@ -0,0 +1,8 @@
1
+ # Spree::AccountLockout reads these columns. Installs upgraded from a Devise
2
+ # admin model without :lockable reach 6.0 with neither of them.
3
+ class AddLockoutColumnsToSpreeAdminUsers < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :spree_admin_users, :failed_attempts, :integer unless column_exists?(:spree_admin_users, :failed_attempts)
6
+ add_column :spree_admin_users, :locked_at, :datetime unless column_exists?(:spree_admin_users, :locked_at)
7
+ end
8
+ end
@@ -0,0 +1,38 @@
1
+ class ScopePaymentSourcesProfileIdUniquenessToPaymentMethod < ActiveRecord::Migration[8.1]
2
+ LEGACY_INDEX_NAME = 'index_payment_sources_on_type_and_gateway_payment_profile_id'.freeze
3
+ INDEX_NAME = 'index_payment_sources_on_type_profile_id_and_payment_method'.freeze
4
+
5
+ def up
6
+ remove_index :spree_payment_sources, name: LEGACY_INDEX_NAME
7
+ add_index :spree_payment_sources, [:type, :gateway_payment_profile_id, :payment_method_id],
8
+ unique: true, name: INDEX_NAME
9
+ end
10
+
11
+ # The legacy index cannot come back once a buyer holds the same gateway profile under two
12
+ # payment methods, which is what `up` exists to allow. Checked before anything is dropped so a
13
+ # refusal leaves both indexes intact on MySQL, which has no transactional DDL.
14
+ def down
15
+ if cross_payment_method_duplicates?
16
+ raise ActiveRecord::IrreversibleMigration,
17
+ 'Payment sources share a gateway_payment_profile_id across payment methods, which ' \
18
+ 'the globally unique index cannot represent. Reconcile those rows before rolling back.'
19
+ end
20
+
21
+ remove_index :spree_payment_sources, name: INDEX_NAME
22
+ add_index :spree_payment_sources, [:type, :gateway_payment_profile_id],
23
+ unique: true, name: LEGACY_INDEX_NAME
24
+ end
25
+
26
+ private
27
+
28
+ # NULLs are distinct in a unique index, so only fully populated pairs could ever have collided.
29
+ def cross_payment_method_duplicates?
30
+ connection.select_value(<<~SQL).present?
31
+ SELECT 1 FROM spree_payment_sources
32
+ WHERE type IS NOT NULL AND gateway_payment_profile_id IS NOT NULL
33
+ GROUP BY type, gateway_payment_profile_id
34
+ HAVING COUNT(*) > 1
35
+ LIMIT 1
36
+ SQL
37
+ end
38
+ end
@@ -0,0 +1,7 @@
1
+ # Written whenever a session starts or refreshes (see Spree::RefreshToken), so
2
+ # it outlives the sessions themselves, which signing out deletes.
3
+ class AddLastActiveAtToSpreeAdminUsers < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :spree_admin_users, :last_active_at, :datetime
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ class DeleteOrphanedSpreeOptionValueVariants < ActiveRecord::Migration[8.1]
2
+ # Replacing a variant's option_value_variants used to nullify the old join rows instead of
3
+ # deleting them. Rows with no variant belong to nothing and block new variants from reusing
4
+ # their option values through the uniqueness validation scoped to a NULL variant_id.
5
+ def up
6
+ execute 'DELETE FROM spree_option_value_variants WHERE variant_id IS NULL'
7
+ end
8
+
9
+ def down; end
10
+ end
@@ -40,7 +40,6 @@ module Spree
40
40
  args << '--skip-git'
41
41
  args << '--skip-keeps'
42
42
  args << '--skip-rc'
43
- args << '--skip-spring'
44
43
  args << '--skip-test'
45
44
  args << '--skip-bootsnap'
46
45
  args << '--skip-docker'
@@ -1,6 +1,3 @@
1
- <% if agent_number = ENV['TC_AGENT_NUMBER']
2
- database_prefix = agent_number + '_'
3
- end %>
4
1
  <% if options[:lib_name]
5
2
  lib_name = options[:lib_name].gsub('/', '_')
6
3
  end %>
@@ -37,13 +34,13 @@ mysql: &mysql
37
34
 
38
35
  development:
39
36
  <<: *mysql
40
- database: <%= database_prefix %><%= lib_name %>_spree_development
37
+ database: <%= lib_name %>_spree_development
41
38
  test:
42
39
  <<: *mysql
43
- database: <%= database_prefix %><%= lib_name %>_spree_test<%%= ENV['TEST_ENV_NUMBER'] %>
40
+ database: <%= lib_name %>_spree_test<%%= ENV['TEST_ENV_NUMBER'] %>
44
41
  production:
45
42
  <<: *mysql
46
- database: <%= database_prefix %><%= lib_name %>_spree_production
43
+ database: <%= lib_name %>_spree_production
47
44
  <% when 'postgres' %>
48
45
  postgres: &postgres
49
46
  adapter: postgresql
@@ -61,13 +58,13 @@ postgres: &postgres
61
58
 
62
59
  development:
63
60
  <<: *postgres
64
- database: <%= database_prefix %><%= lib_name %>_spree_development
61
+ database: <%= lib_name %>_spree_development
65
62
  test:
66
63
  <<: *postgres
67
- database: <%= database_prefix %><%= lib_name %>_spree_test<%%= ENV['TEST_ENV_NUMBER'] %>
64
+ database: <%= lib_name %>_spree_test<%%= ENV['TEST_ENV_NUMBER'] %>
68
65
  production:
69
66
  <<: *postgres
70
- database: <%= database_prefix %><%= lib_name %>_spree_production
67
+ database: <%= lib_name %>_spree_production
71
68
  <% else %>
72
69
  development:
73
70
  adapter: sqlite3
@@ -1,11 +1,11 @@
1
- Dummy::Application.configure do
1
+ Rails.application.configure do
2
2
  # Settings specified here will take precedence over those in config/application.rb
3
3
 
4
4
  # The test environment is used exclusively to run your application's
5
5
  # test suite. You never need to work with it otherwise. Remember that
6
6
  # your test database is "scratch space" for the test suite and is wiped
7
7
  # and recreated between test runs. Don't rely on the data there!
8
- config.cache_classes = true
8
+ config.enable_reloading = false
9
9
 
10
10
  # Configure static asset server for tests with Cache-Control for performance
11
11
  config.public_file_server.enabled = true
@@ -17,8 +17,9 @@ Dummy::Application.configure do
17
17
 
18
18
  config.eager_load = false
19
19
 
20
- # Raise exceptions instead of rendering exception templates
21
- config.action_dispatch.show_exceptions = false
20
+ # Raise unexpected exceptions; render the ones Rails maps to a status, such
21
+ # as a 404 for an unknown route, the way production does.
22
+ config.action_dispatch.show_exceptions = :rescuable
22
23
 
23
24
  # Disable request forgery protection in test environment
24
25
  config.action_controller.allow_forgery_protection = false
@@ -138,7 +139,10 @@ Dummy::Application.configure do
138
139
  %r{/spree/orders/complete\.rb:\d+:in '[^']*#place_sibling'},
139
140
  # Each requested line is priced, stock-checked, saved and taxed as it
140
141
  # is written.
141
- %r{/spree/carts/upsert_items\.rb:\d+:in '[^']*#apply_item'}
142
+ %r{/spree/carts/upsert_items\.rb:\d+:in '[^']*#apply_item'},
143
+ # An import writes each row on its own, inside the request when the
144
+ # import is set to run inline.
145
+ %r{/spree/import_row\.rb:\d+:in '[^']*#process!'}
142
146
  ]
143
147
 
144
148
  # A purchase's billing and shipping addresses, and a transfer's two
@@ -1,4 +1,4 @@
1
- class CreateSpreeDummyModels < ActiveRecord::Migration[5.1]
1
+ class CreateSpreeDummyModels < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :spree_dummy_models do |t|
4
4
  t.string :name
@@ -10,6 +10,12 @@ module Spree
10
10
  #
11
11
  # @see Requirements
12
12
  class DefaultRequirements
13
+ # Everything the discontinuation and stock checks read, so a set of
14
+ # lines is checked at once.
15
+ STOCK_CHECK_ASSOCIATIONS = {
16
+ variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
17
+ }.freeze
18
+
13
19
  # @param cart [Spree::Cart]
14
20
  def initialize(cart)
15
21
  @cart = cart
@@ -33,6 +39,7 @@ module Spree
33
39
  r << req('address', 'email', I18n.t('spree.checkout_requirements.email_required')) unless @cart.email.present?
34
40
  r << req('address', 'ship_address', I18n.t('spree.checkout_requirements.ship_address_required')) if @cart.shipping_address_required? && @cart.ship_address.blank?
35
41
  r << req('delivery', 'delivery_method', I18n.t('spree.checkout_requirements.delivery_method_required')) if delivery_step_required? && !delivery_method_selected?
42
+ r.concat(undeliverable_errors)
36
43
  r << req('payment', 'payment', I18n.t('spree.checkout_requirements.payment_required')) if payment_required? && !payment_satisfied?
37
44
  r << req('address', 'po_number', I18n.t('spree.checkout_requirements.po_number_required')) if po_number_missing?
38
45
  r << order_minimum_requirement if below_order_minimum?
@@ -102,16 +109,37 @@ module Spree
102
109
  end
103
110
  end
104
111
 
112
+ # A rebuild drops a proposal no method can quote with only a transient
113
+ # warning, so part of a line can be in no fulfillment. Advisory, so the
114
+ # buyer reads it while they can still change the address or the cart.
115
+ def undeliverable_errors
116
+ return [] unless delivery_step_required? && @cart.fulfillments.any?
117
+
118
+ undelivered_line_items.map do |line_item|
119
+ req('delivery', 'line_items', I18n.t('spree.cart_line_item.delivery_unavailable', li_name: line_item.name),
120
+ code: 'delivery_unavailable')
121
+ end
122
+ end
123
+
124
+ # A line short of stock is packed short too; the stock check already
125
+ # names it. Only lines short of a fulfillment pay for that check, so a
126
+ # cart read stays at one query.
127
+ def undelivered_line_items
128
+ allocated = Spree::FulfillmentItem.where(fulfillment_id: @cart.fulfillments.select(:id))
129
+ .group(:line_item_id).sum(:quantity)
130
+ short = @cart.line_items.select { |line_item| allocated.fetch(line_item.id, 0) < line_item.quantity }
131
+ return [] if short.empty?
132
+
133
+ ActiveRecord::Associations::Preloader.new(records: short, associations: STOCK_CHECK_ASSOCIATIONS).call
134
+ short.reject { |line_item| discontinued?(line_item) || !line_item.sufficient_stock? }
135
+ end
136
+
105
137
  def discontinued?(line_item)
106
138
  line_item.variant.nil? || line_item.variant.discontinued? || line_item.variant.product.discontinued?
107
139
  end
108
140
 
109
141
  def completion_line_items
110
- # Everything the discontinuation and stock checks read, for every line
111
- # at once.
112
- @completion_line_items ||= @cart.line_items.includes(
113
- variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
114
- ).to_a
142
+ @completion_line_items ||= @cart.line_items.includes(STOCK_CHECK_ASSOCIATIONS).to_a
115
143
  end
116
144
 
117
145
  def delivery_step_required?
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.0'.freeze
2
+ VERSION = '6.0.2'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -39,8 +39,6 @@ require 'ransack'
39
39
  require 'active_storage_validations'
40
40
  require 'wannabe_bool'
41
41
  require 'geocoder'
42
-
43
- require 'safely_block'
44
42
  require 'ar_lazy_preload'
45
43
  require 'sqids'
46
44
 
@@ -125,7 +123,6 @@ module Spree
125
123
  collections: :default,
126
124
  stock_location_stock_levels: :default,
127
125
  coupon_codes: :default,
128
- themes: :default,
129
126
  addresses: :default,
130
127
  gift_cards: :default,
131
128
  webhooks: :default,
@@ -666,6 +663,7 @@ require 'spree/i18n'
666
663
  require 'spree/iso_data'
667
664
  require 'spree/localized_number'
668
665
  require 'spree/measurement'
666
+ require 'spree/time_zones'
669
667
  require 'spree/translations'
670
668
  require 'spree/money'
671
669
  require 'spree/service_module'
@@ -3,11 +3,12 @@ require 'generators/spree/dummy_model/dummy_model_generator'
3
3
 
4
4
  desc 'Generates a dummy app for testing'
5
5
  namespace :common do
6
+ # :authentication is ignored (test apps use the gem-owned auth models); it stays
7
+ # so extensions passing positional arguments keep working.
6
8
  task :test_app, [:authentication, :user_class, :admin_user_class] do |_t, args|
7
9
  # Support both Rake::TaskArguments (via invoke) and Hash (via execute)
8
10
  # When using execute with a Hash, args IS the hash directly
9
11
  defaults = {
10
- authentication: 'dummy',
11
12
  user_class: 'Spree::Customer',
12
13
  admin_user_class: 'Spree::AdminUser',
13
14
  }
@@ -45,7 +46,7 @@ namespace :common do
45
46
  File.exist?(File.expand_path('../../../../spree.gemspec', __dir__))
46
47
  bundle_exec = use_root_gemfile ? "bundle exec --gemfile=#{root_gemfile}" : 'bundle exec'
47
48
  puts 'Running Spree install generator...'
48
- system("#{bundle_exec} rails g spree:install --force --auto-accept --migrate=false --seed=false --sample=false --user_class=#{args[:user_class]} --admin_user_class=#{args[:admin_user_class]} --authentication=#{args[:authentication]}")
49
+ system("#{bundle_exec} rails g spree:install --force --auto-accept --migrate=false --seed=false --sample=false --user_class=#{args[:user_class]} --admin_user_class=#{args[:admin_user_class]}")
49
50
 
50
51
  unless ENV['NO_MIGRATE']
51
52
  puts 'Setting up dummy database...'
@@ -23,9 +23,7 @@ shared_examples_for 'lifecycle events' do |factory: nil, event_prefix: nil|
23
23
 
24
24
  # update_attribute, not update!: Spree::Fulfillment#update! shadows the
25
25
  # Active Record method with the legacy state-recalculation API.
26
- Timecop.travel(1.minute.from_now) do
27
- record.update_attribute(:updated_at, Time.current)
28
- end
26
+ record.update_attribute(:updated_at, 1.minute.from_now)
29
27
  end
30
28
 
31
29
  it 'does not publish updated event on a bare touch' do
@@ -0,0 +1,34 @@
1
+ module Spree
2
+ # Time zones are stored and exchanged as IANA identifiers (`Europe/Berlin`),
3
+ # the names browsers and every non-Ruby client understand. Rails' display
4
+ # names ("Eastern Time (US & Canada)") are converted to them on the way in.
5
+ module TimeZones
6
+ # The zones clients are offered: the IANA zones Rails gives a display name
7
+ # to, with plain `UTC` standing in for `Etc/UTC`. Any other valid IANA
8
+ # identifier is accepted too. The list comes with the Rails version rather
9
+ # than the machine's time zone database, so generated types match wherever
10
+ # the same Rails version runs.
11
+ SUGGESTED = (ActiveSupport::TimeZone::MAPPING.values - ['Etc/UTC'] + ['UTC']).uniq.sort.freeze
12
+
13
+ # @param identifier [String, nil]
14
+ # @return [Boolean] whether the value is an IANA time zone identifier
15
+ def self.valid?(identifier)
16
+ return false if identifier.blank?
17
+
18
+ TZInfo::Timezone.get(identifier.to_s)
19
+ true
20
+ rescue TZInfo::InvalidTimezoneIdentifier
21
+ false
22
+ end
23
+
24
+ # @param value [String, nil] an IANA identifier or a Rails time zone name
25
+ # @return [String, nil] the IANA identifier, or the value unchanged when it
26
+ # names no zone the installed time zone database knows
27
+ def self.normalize(value)
28
+ return value if value.blank? || valid?(value)
29
+
30
+ identifier = ActiveSupport::TimeZone::MAPPING[value.to_s]
31
+ valid?(identifier) ? identifier : value
32
+ end
33
+ end
34
+ end
@@ -212,7 +212,8 @@ steps:
212
212
  task: "spree:migrate_shipping_to_delivery"
213
213
  notes: |
214
214
  Rewrites stored 'Spree::Shipment' class-name strings (state changes,
215
- stock movements, legacy adjustments), renames the terminal fulfillment
215
+ stock movements, legacy adjustments) and the calculators' 'Spree::ShippingMethod'
216
+ calculable type (so delivery methods find their calculator), renames the terminal fulfillment
216
217
  status 'shipped' -> 'fulfilled', backfills the fulfillment provider on
217
218
  delivery methods (DigitalDelivery calculators become the Digital
218
219
  provider) and copies the provider-derived type onto existing
@@ -597,6 +598,19 @@ steps:
597
598
  is dropped in 6.1. Idempotent — only rows without a capture_method are
598
599
  written.
599
600
 
601
+ - id: store_regional_settings
602
+ name: "Convert store time zones and admin languages to standard codes"
603
+ task_class: "Spree::MaintenanceTasks::Upgrade::StoreRegionalSettings"
604
+ notes: |
605
+ A store's time zone is now always an IANA name such as America/New_York,
606
+ and its admin language a locale code Spree lists, the forms the API
607
+ accepts and browsers understand. Rails time zone names such as
608
+ "Eastern Time (US & Canada)" and locale spellings such as en_US are
609
+ converted. A time zone that names no zone is reset to the store default,
610
+ which is what the store already ran on; an admin language Spree does not
611
+ list is cleared, so staff see their own language. Idempotent — stores
612
+ already on standard codes are skipped.
613
+
600
614
  - id: product_types_backfill
601
615
  name: "Backfill product type store ownership"
602
616
  task: "spree:product_types:backfill"
@@ -29,6 +29,11 @@ namespace :spree do
29
29
  say.call "spree_adjustments.adjustable_type: #{count} rows Spree::Shipment → Spree::Fulfillment"
30
30
  end
31
31
 
32
+ # Calculators are polymorphic on the renamed shipping method; without this
33
+ # DeliveryMethod#calculator finds nothing.
34
+ count = Spree::Calculator.where(calculable_type: 'Spree::ShippingMethod').in_batches(of: batch_size).update_all(calculable_type: 'Spree::DeliveryMethod')
35
+ say.call "spree_calculators.calculable_type: #{count} rows Spree::ShippingMethod → Spree::DeliveryMethod"
36
+
32
37
  # The name column distinguishes the machine ('shipment' rows become
33
38
  # 'fulfillment'); the recorded states keep their history except the
34
39
  # renamed terminal value.
@@ -171,6 +176,11 @@ namespace :spree do
171
176
  end
172
177
  end
173
178
 
179
+ # 5.x methods have no store until backfill_delivery_and_stock_store_ids
180
+ # runs (later in the upgrade), and a DeliveryZone requires one. Resolve
181
+ # them to the default store, exactly as that backfill will.
182
+ default_store_id = Spree::Store.default&.id
183
+
174
184
  legacy_zones.find_each do |zone|
175
185
  if Spree::DeliveryZone.exists?(id: zone.id) && !migrated.value?(zone.id)
176
186
  puts "SKIP zone #{zone.id} (#{zone.name}): a DeliveryZone with the same id already exists — resolve manually"
@@ -178,7 +188,7 @@ namespace :spree do
178
188
  end
179
189
 
180
190
  methods_by_home = Spree::DeliveryMethod.unscoped.where(delivery_zone_id: zone.id).
181
- group_by { |method| [method.store_id, method.delivery_profile_id] }
191
+ group_by { |method| [method.store_id || default_store_id, method.delivery_profile_id] }
182
192
 
183
193
  methods_by_home.each do |(store_id, profile_id), methods|
184
194
  profile_id ||= Spree::DeliveryProfile.where(store_id: store_id, default: true).pick(:id)
@@ -208,6 +218,12 @@ namespace :spree do
208
218
  puts "SKIP unreferenced shipping zone #{zone.id} (#{zone.name}) — no delivery method uses it"
209
219
  end
210
220
 
221
+ zoneless = Spree::DeliveryMethod.unscoped.where(delivery_zone_id: nil).pluck(:id, :name)
222
+ if zoneless.any?
223
+ puts "WARNING: #{zoneless.size} delivery methods have no delivery zone and return no rates at checkout. Assign a zone to each in the admin:"
224
+ zoneless.each { |id, name| puts " - ##{id} #{name}" }
225
+ end
226
+
211
227
  puts 'migrate_zones_to_delivery_zones done. Tax-scoped zones stay on Spree::Zone for the tax provider migration.'
212
228
  end
213
229
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deleting a promotion used to leave OrderPromotion rows on open carts pointing
4
+ # at a promotion that no longer exists, which broke loading those carts.
5
+ namespace :spree do
6
+ desc 'Remove order promotions whose promotion no longer exists and recalculate the affected carts'
7
+ task remove_orphan_order_promotions: :environment do
8
+ orphans = Spree::OrderPromotion.where.not(promotion_id: Spree::Promotion.unscoped.select(:id))
9
+ removed = 0
10
+
11
+ orphans.find_each do |order_promotion|
12
+ owner = order_promotion.owner
13
+ order_promotion.destroy
14
+ owner&.recalculate_totals! if owner.is_a?(Spree::Cart)
15
+ removed += 1
16
+ end
17
+
18
+ puts "remove_orphan_order_promotions done. removed: #{removed}"
19
+ end
20
+ end