spree_core 6.0.0 → 6.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 579b3e333d378aec89721e12502e86fe2f529d99e18f087ecaf29cebe6e6b772
4
- data.tar.gz: 29411570d9588634cfa2a4f559bea3bf6622c8672b90415a38ee1372a996494f
3
+ metadata.gz: 2407d5fb2d24d9dd893a8c9dae11cfd8aba5a4b1d621a82938a2cb7009581936
4
+ data.tar.gz: f3384773e3442f383e979f60d58b7b06672a72065cd8d117a114ef49827fda5e
5
5
  SHA512:
6
- metadata.gz: 38eef7cff0fce557dd3b14460d3bbdf50e410fc28ae7e9c74fa59f92767078cc74531f221d7a615ba53af0297c3dd8bd9e72ab15a5f09dd5ab88ceff20b0e653
7
- data.tar.gz: dd2f38e2a6c8b179610ae41ded85992b051081ed71ac110e3a078ebd595b4ea273bbdabf6b64226af1831ce7e93eda1e32591dda4f5da9969dc700aaa3d6f587
6
+ metadata.gz: ebc83d9db9ebc13afea53ae0b184789f32a81121dcd9608461d93fb7e2e62b020cef774e69de3404fc0d23adb62df13f770281eacd6fd4671e5c0f5328ecae5b
7
+ data.tar.gz: 99ecc2439e9a73c1f805898c35e48079cd573b947110951c1a8349b26934e7ea8023b55a53cfdf1b3af94a5013290c52e5a097e4e5dff61d1249af8ca3327fb7
@@ -1,6 +1,7 @@
1
1
  module Spree
2
2
  module SellerTransfers
3
- # Hands every seller holding an unconfirmed earning to a job of their own.
3
+ # Hands every seller holding an unconfirmed earning or reversal to a job of
4
+ # their own.
4
5
  #
5
6
  # The transfer level's counterpart to {Spree::SellerPayouts::SweepDueJob},
6
7
  # and deliberately a separate schedule rather than something the settlement
@@ -39,17 +40,18 @@ module Spree
39
40
  # @param store [Spree::Store]
40
41
  # @return [ActiveRecord::Relation<Spree::Seller>]
41
42
  def sellers_awaiting_provider(store)
42
- scope = store.sellers.approved.
43
- where(id: Spree::SellerTransfer.for_store(store).awaiting_provider.select(:seller_id))
43
+ transfers = Spree::SellerTransfer.for_store(store)
44
+ sending = store.sellers.approved.where(id: transfers.awaiting_provider.select(:seller_id))
44
45
 
45
46
  # A seller the provider will not accept yet keeps their earnings
46
47
  # unconfirmed, and the job would return at once. Skipped rather than
47
48
  # enqueued, because a marketplace's half-onboarded sellers are a
48
49
  # standing population and this runs often — and nothing is lost by it:
49
50
  # finishing onboarding runs the job for them directly.
50
- return scope unless store.payout_provider_class.requires_payout_account?
51
+ sending = sending.where.not(payouts_enabled_at: nil) if store.payout_provider_class.requires_payout_account?
51
52
 
52
- scope.where.not(payouts_enabled_at: nil)
53
+ # Money owed back is pursued whatever the seller's standing.
54
+ sending.or(store.sellers.where(id: transfers.reversals_awaiting_provider.select(:seller_id)))
53
55
  end
54
56
  end
55
57
  end
@@ -14,6 +14,9 @@ module Spree
14
14
  # seller who was payable all along would never see a refused earning tried
15
15
  # again, since nothing about them ever changes to prompt it.
16
16
  #
17
+ # Reversals are retried the same way: a provider that pulls money back can
18
+ # refuse too, and one cannot be pulled back before its earning was sent.
19
+ #
17
20
  # It takes the retryable rows — never sent, or refused outright. A transfer
18
21
  # whose outcome the provider could not report is left alone: asking again
19
22
  # is precisely how the same money moves twice, and only a person can say
@@ -24,30 +27,47 @@ module Spree
24
27
  # @param seller_id [Integer]
25
28
  def perform(seller_id)
26
29
  seller = Spree::Seller.find_by(id: seller_id)
27
- return if seller.nil? || !seller.payouts_enabled?
30
+ return if seller.nil?
28
31
 
29
32
  Spree::Current.store = seller.store
33
+ send_earnings(seller) if seller.payouts_enabled?
34
+
35
+ # Never through `transfer!`: a reversal's amount is negative, and the
36
+ # amount sent is its absolute value, so it would pay the seller the
37
+ # money it exists to take back. Through the provider that made the
38
+ # earning, which is not necessarily the store's current one. And for
39
+ # every seller, payable or not — a seller suspended after a wave of
40
+ # refunds is exactly the one whose money must come back.
41
+ seller.seller_transfers.reversals_awaiting_provider.includes(:reversed_from, :order).find_each do |reversal|
42
+ execute(reversal) { reversal.payout_provider_instance.reverse!(reversal) }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Before the reversals, so a reversal waiting on its earning finds it sent.
49
+ def send_earnings(seller)
30
50
  provider = seller.store.payout_provider_instance
31
51
 
32
- # Earnings only — see the scope. A reversal is retryable the same way
33
- # when its own provider call is refused, but sending one through
34
- # `transfer!` would pay the seller the amount it exists to take back:
35
- # the row is negative and the amount sent is its absolute value.
36
52
  seller.seller_transfers.awaiting_provider.find_each do |transfer|
37
- provider.transfer!(transfer)
38
- rescue Spree::Core::AmbiguousGatewayError => e
39
- # The money may already have moved, so this row must not be offered
40
- # again: left retryable, the next run would send it a second time,
41
- # and an idempotency key stops that only while the provider still
42
- # holds its record.
43
- transfer.update!(status: 'unresolved')
44
- Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
45
- rescue StandardError => e
46
- # A refusal — the money did not move. One seller's stuck earning must
47
- # not stop the rest, and the row stays retryable for the next run.
48
- Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
53
+ execute(transfer) { provider.transfer!(transfer) }
49
54
  end
50
55
  end
56
+
57
+ def execute(transfer)
58
+ yield
59
+ rescue Spree::Core::AmbiguousGatewayError => e
60
+ # The money may already have moved, so this row must not be offered
61
+ # again: left retryable, the next run would move it a second time,
62
+ # and an idempotency key stops that only while the provider still
63
+ # holds its record.
64
+ transfer.update!(status: 'unresolved')
65
+ Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
66
+ rescue StandardError => e
67
+ # A refusal — the money did not move. One stuck row must not stop the
68
+ # rest, and it stays retryable for the next run.
69
+ Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
70
+ end
51
71
  end
52
72
  end
53
73
  end
@@ -0,0 +1,63 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # 5.6 → 6.0 conversion of the store's time zone into an IANA identifier
5
+ # ("Eastern Time (US & Canada)" becomes "America/New_York") and of its
6
+ # admin language into a listed locale code (`en_US` becomes `en-US`), the
7
+ # only forms the API accepts and browsers read.
8
+ #
9
+ # A time zone that names no zone falls back to the store default, which is
10
+ # what the store already ran on: Ruby could not read it either. An admin
11
+ # language Spree does not list is cleared, so the dashboard shows each
12
+ # staff member their own language.
13
+ class StoreRegionalSettings < Spree::MaintenanceTask
14
+ supports_dry_run
15
+
16
+ def collection
17
+ Spree::Store.with_deleted.order(:id)
18
+ end
19
+
20
+ # Locks the store's row, so a settings save made meanwhile is read
21
+ # rather than overwritten, and counts each change once it is written.
22
+ #
23
+ # @param store [Spree::Store]
24
+ # @return [void]
25
+ def process(store)
26
+ store.with_lock do
27
+ changes = time_zone_change(store).merge(admin_locale_change(store))
28
+ next if changes.empty?
29
+
30
+ if dry_run?
31
+ changes.each_value { |outcome, _value| tally(:"would_#{outcome}") }
32
+ next
33
+ end
34
+
35
+ changes.each { |name, (_outcome, value)| store.set_preference(name, value) }
36
+ store.update_columns(preferences: store.preferences, updated_at: Time.current)
37
+ changes.each_value { |outcome, _value| tally(outcome) }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def time_zone_change(store)
44
+ stored = store.preferred_timezone
45
+ return {} if Spree::TimeZones.valid?(stored)
46
+
47
+ converted = Spree::TimeZones.normalize(stored)
48
+ return { timezone: [:time_zone_converted, converted] } if Spree::TimeZones.valid?(converted)
49
+
50
+ { timezone: [:time_zone_reset_to_default, store.preference_default(:timezone)] }
51
+ end
52
+
53
+ def admin_locale_change(store)
54
+ stored = store.preferred_admin_locale
55
+ return {} if stored.blank? || Spree::Locales::ALL.include?(stored)
56
+
57
+ listed = Spree::Locales.listed(stored)
58
+ { admin_locale: listed ? [:admin_locale_converted, listed] : [:admin_locale_cleared, nil] }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -5,6 +5,9 @@ module Spree
5
5
  included do
6
6
  extend Mobility
7
7
  default_scope { i18n }
8
+
9
+ # Before the translations are autosaved, so it has to run first.
10
+ after_update :discard_blank_new_translations, prepend: true
8
11
  end
9
12
 
10
13
  class_methods do
@@ -178,6 +181,22 @@ module Spree
178
181
 
179
182
  private
180
183
 
184
+ # Clearing a translation's last value deletes its row, and Mobility then
185
+ # rebuilds a blank one for that locale while it finalizes dirty tracking.
186
+ # Saved, that blank row is either junk or, where translation rows are soft
187
+ # deleted (stores), a duplicate of the deleted row.
188
+ def discard_blank_new_translations
189
+ return unless association(:translations).loaded?
190
+
191
+ columns = self.class.translatable_fields.map(&:to_s)
192
+ translations.each do |translation|
193
+ next unless translation.new_record?
194
+ next if columns.any? { |column| translation[column].present? }
195
+
196
+ translation.mark_for_destruction
197
+ end
198
+ end
199
+
181
200
  # @raise [ActiveRecord::RecordInvalid] if any locale isn't supported by the store
182
201
  def validate_translation_locales!(locales)
183
202
  supported = translatable_store&.supported_locales_list || []
@@ -116,6 +116,15 @@ module Spree
116
116
  publish_event('invitation.resent')
117
117
  end
118
118
 
119
+ # The staff account holding the invited address, if one exists. Matched
120
+ # the way the account's email uniqueness is, case-insensitively, so an
121
+ # address typed in another case still finds the one account it can belong
122
+ # to. Resolved by record rather than by comparing strings in Ruby, whose
123
+ # case folding is wider than the database's.
124
+ def invitee_account
125
+ Spree.admin_user_class.where('LOWER(email) = LOWER(?)', email).first
126
+ end
127
+
119
128
  private
120
129
 
121
130
  def publish_invitation_created_event
@@ -161,7 +170,7 @@ module Spree
161
170
  return if resource.blank?
162
171
 
163
172
  # Check role_users directly for better performance and to avoid potential source_type issues
164
- user_to_check = invitee || Spree.admin_user_class.find_by(email: email)
173
+ user_to_check = invitee || invitee_account
165
174
  return if user_to_check.blank?
166
175
 
167
176
  if resource.role_users.exists?(user: user_to_check)
@@ -172,7 +181,7 @@ module Spree
172
181
  def set_invitee_from_email
173
182
  return if invitee.present?
174
183
 
175
- self.invitee = Spree.admin_user_class.find_by(email: email)
184
+ self.invitee = invitee_account
176
185
  end
177
186
  end
178
187
  end
@@ -10,7 +10,7 @@ module Spree
10
10
  # See docs/plans/6.0-order-routing.md.
11
11
  class Rules < Base
12
12
  def for_allocation
13
- locations = eligible_locations
13
+ locations = coordinator.candidate_stock_locations
14
14
  return [] if locations.empty?
15
15
 
16
16
  ordered = Spree::OrderRouting::Strategy::Reducer
@@ -18,9 +18,11 @@ module Spree
18
18
  .rank_all(locations)
19
19
  return [] if ordered.empty?
20
20
 
21
- packages = build_packages(ordered)
22
- packages = prioritize_packages(packages)
23
- estimate_rates(packages)
21
+ # Packed in rank order through the Coordinator, so an order allocates
22
+ # under the same delivery profile and channel coverage as its cart,
23
+ # and the Prioritizer's first-package-wins-on-hand logic honors the
24
+ # routing decision between origins that can reach the destination.
25
+ coordinator.estimate_packages(coordinator.allocated_packages(ordered))
24
26
  end
25
27
 
26
28
  # Stock decrement / restock today happens via Spree::Fulfillment's state
@@ -39,50 +41,8 @@ module Spree
39
41
  order.channel.order_routing_rules.active.ordered
40
42
  end
41
43
 
42
- def eligible_locations
43
- order.store.stock_locations.active
44
- .joins(:stock_levels)
45
- .where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids })
46
- .distinct
47
- .to_a
48
- end
49
-
50
- def requested_variant_ids
51
- inventory_units.map(&:variant_id).uniq
52
- end
53
-
54
- def inventory_units
55
- @inventory_units ||= Spree::Stock::InventoryUnitBuilder.new(order).units
56
- end
57
-
58
- # Pack each ranked location independently. Packages are emitted in
59
- # rank order so the Prioritizer's first-package-wins-on-hand logic
60
- # honors the routing decision.
61
- def build_packages(locations)
62
- stock_levels = Spree::Stock::Packer.stock_levels_by_location(locations, requested_variant_ids)
63
-
64
- locations.flat_map do |location|
65
- Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters, owner: order,
66
- stock_levels: stock_levels.fetch(location.id, {})).packages
67
- end
68
- end
69
-
70
- # Prioritizer's Adjuster distributes each inventory_unit across
71
- # packages: the first package with on-hand stock fulfills the unit,
72
- # and downstream packages have that unit removed. Packages whose
73
- # items all get stripped are pruned. Rank only decides between origins
74
- # that can reach the destination — the Prioritizer demotes the rest.
75
- def prioritize_packages(packages)
76
- Spree::Stock::Prioritizer.new(packages, estimator: estimator).prioritized_packages
77
- end
78
-
79
- def estimate_rates(packages)
80
- packages.each { |pkg| pkg.delivery_rates = estimator.delivery_rates(pkg) }
81
- packages
82
- end
83
-
84
- def estimator
85
- @estimator ||= Spree::Stock::Estimator.new(order)
44
+ def coordinator
45
+ @coordinator ||= Spree::Stock::Coordinator.new(order)
86
46
  end
87
47
  end
88
48
  end
@@ -89,6 +89,16 @@ module Spree
89
89
  # amount is negative, and sending one as a transfer pays the seller the
90
90
  # money it exists to take back.
91
91
  scope :awaiting_provider, -> { earnings.retryable.where(payout_id: nil) }
92
+ # Reversals still owing the provider a call: refused, or waiting for their
93
+ # earning to reach the provider first. Kept apart from `awaiting_provider`
94
+ # so a caller has to choose `reverse!` for these by name.
95
+ #
96
+ # A reversal of an earning nobody can account for waits with it: there is
97
+ # nothing to take back from until a person settles what happened.
98
+ scope :reversals_awaiting_provider, lambda {
99
+ reversals_only.retryable.where(payout_id: nil).
100
+ where.not(reversed_from_id: Spree::SellerTransfer.with_status('unresolved').select(:id))
101
+ }
92
102
  # Earned and confirmed, but not yet swept into a settlement — what the next
93
103
  # payout will pick up.
94
104
  scope :unsettled, -> { completed.where(payout_id: nil) }
@@ -138,6 +148,47 @@ module Spree
138
148
  [amount - reversals.sum(:amount).abs, 0].max
139
149
  end
140
150
 
151
+ # The provider that moved this row's money, not whichever one the store
152
+ # uses now. A marketplace that changes provider still has money sitting
153
+ # with the old one, and asking the new one to reverse a transfer it never
154
+ # made leaves the original standing.
155
+ #
156
+ # No silent fallback when that provider is no longer installed: handing
157
+ # the row to a different one would mark it done while the money stayed
158
+ # where it was. An operator has to know.
159
+ #
160
+ # @return [Spree::PayoutProvider::Base]
161
+ # @raise [Spree::Core::GatewayError] when the provider is not registered
162
+ def payout_provider_instance
163
+ configured = Spree::ApiTyped.registered_class(Spree.payout_providers, provider)
164
+ raise Spree::Core::GatewayError, "Payout provider #{provider} is not registered" if configured.nil?
165
+
166
+ configured.new
167
+ end
168
+
169
+ # The settlement a reversal of part of this earning records.
170
+ #
171
+ # A clawback has to settle where its earning settled. Payouts are swept by
172
+ # settlement currency, so a reversal left in the sale's currency would
173
+ # never join the batch that pays the earning it cancels — the seller would
174
+ # be paid in full for goods that came back.
175
+ #
176
+ # Prorated from this earning's settled figure rather than converted at
177
+ # today's rate, so the money comes back at the rate it went out at.
178
+ #
179
+ # @param part [BigDecimal] how much of this earning is reversed, positive
180
+ # @return [Hash] negative `settled_amount` and `settled_currency`
181
+ def reversal_settlement(part)
182
+ return {} if settled_amount.blank? || amount.zero?
183
+
184
+ share = settled_amount * (part / amount)
185
+
186
+ {
187
+ settled_amount: -Spree::Money::Rounding.quantize(share, Spree::Money::Rounding.precision(settlement_currency)),
188
+ settled_currency: settled_currency
189
+ }
190
+ end
191
+
141
192
  # @return [Boolean]
142
193
  def earning?
143
194
  kind == 'earning'
@@ -22,13 +22,45 @@ module Spree
22
22
  end
23
23
 
24
24
  def packages
25
- packages = build_packages
26
- packages = prioritize_packages(packages)
27
- packages = estimate_packages(packages)
25
+ estimate_packages(allocated_packages)
28
26
  end
29
27
 
30
- def build_packages(packages = [])
31
- stock_locations = stock_locations_with_requested_variants.to_a
28
+ # Where each unit comes from, without quoting a rate, so a caller that
29
+ # only needs the origins — a checkout hold — never costs a carrier request.
30
+ #
31
+ # @param stock_locations [Array<Spree::StockLocation>] candidate origins, preferred first
32
+ # @return [Array<Spree::Stock::Package>]
33
+ def allocated_packages(stock_locations = candidate_stock_locations)
34
+ prioritize_packages(build_packages(stock_locations: stock_locations))
35
+ end
36
+
37
+ # Whether any delivery method can carry the package to the order's
38
+ # address, by the rule allocation ranked origins with, so no rate is requested.
39
+ #
40
+ # @param package [Spree::Stock::Package]
41
+ # @return [Boolean]
42
+ def deliverable?(package)
43
+ estimator.deliverable?(package)
44
+ end
45
+
46
+ # @param packages [Array<Spree::Stock::Package>]
47
+ # @return [Array<Spree::Stock::Package>] the same packages, with their delivery rates
48
+ def estimate_packages(packages)
49
+ packages.each do |package|
50
+ package.delivery_rates = estimator.delivery_rates(package)
51
+ end
52
+ packages
53
+ end
54
+
55
+ # Active locations stocking any requested variant, in database order.
56
+ #
57
+ # @return [Array<Spree::StockLocation>]
58
+ def candidate_stock_locations
59
+ order.store.stock_locations.active.joins(:stock_levels)
60
+ .where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids }).distinct.to_a
61
+ end
62
+
63
+ def build_packages(packages = [], stock_locations: candidate_stock_locations)
32
64
  stock_levels = Packer.stock_levels_by_location(stock_locations, requested_variant_ids)
33
65
 
34
66
  stock_locations.each do |stock_location|
@@ -44,11 +76,6 @@ module Spree
44
76
 
45
77
  private
46
78
 
47
- def stock_locations_with_requested_variants
48
- order.store.stock_locations.active.joins(:stock_levels).
49
- where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids }).distinct
50
- end
51
-
52
79
  # An item may only be allocated from locations its delivery profile
53
80
  # covers — a profile narrowed to the cold-storage warehouse never packs
54
81
  # from anywhere else — intersected with the channel's served set when
@@ -104,13 +131,6 @@ module Spree
104
131
  prioritizer.prioritized_packages
105
132
  end
106
133
 
107
- def estimate_packages(packages)
108
- packages.each do |package|
109
- package.delivery_rates = estimator.delivery_rates(package)
110
- end
111
- packages
112
- end
113
-
114
134
  # Shared between prioritization (which origins can deliver at all) and
115
135
  # the rate refresh, so both answer with the same eligibility rules.
116
136
  def estimator
@@ -44,25 +44,35 @@ module Spree
44
44
  package = build_package
45
45
 
46
46
  # Group by variant_id as grouping by variant fires cached query.
47
- units_by_variant_id = inventory_units.index_by(&:variant_id)
47
+ units_by_variant_id = inventory_units.group_by(&:variant_id)
48
48
  # The package's variants and their levels here, each in one query
49
49
  # rather than one per variant. Lowest id first, as StockLocation#stock_level reads.
50
50
  variants = variants_for(units_by_variant_id)
51
51
  stock_levels = @stock_levels || stock_location.stock_levels.where(variant_id: units_by_variant_id.keys).order(:id).
52
52
  group_by(&:variant_id).transform_values(&:first)
53
53
 
54
- units_by_variant_id.each do |variant_id, inventory_unit|
54
+ units_by_variant_id.each do |variant_id, units|
55
55
  variant = variants[variant_id] || Spree::Variant.find(variant_id)
56
- unit = inventory_unit.dup # Can be used by others, do not use directly
56
+ # Can be used by others, do not use directly
57
+ units = units.map(&:dup)
57
58
  if variant.should_track_inventory?
58
59
  stock_level = stock_levels[variant_id]
59
60
  next if stock_level.nil?
60
61
 
61
- on_hand, backordered = stock_location.fill_status(variant, unit.quantity, stock_level: stock_level)
62
- package.add(FulfillmentItem.split(unit, backordered), :backordered) if backordered.positive?
63
- package.add(FulfillmentItem.split(unit, on_hand), :on_hand) if on_hand.positive?
62
+ on_hand, backordered = stock_location.fill_status(variant, units.sum(&:quantity), stock_level: stock_level)
63
+ # Two lines of one variant draw on one shelf, so it is split
64
+ # between them in order rather than offered to each in full.
65
+ units.each do |unit|
66
+ unit_on_hand = [on_hand, unit.quantity].min
67
+ unit_backordered = [backordered, unit.quantity - unit_on_hand].min
68
+ on_hand -= unit_on_hand
69
+ backordered -= unit_backordered
70
+
71
+ package.add(FulfillmentItem.split(unit, unit_backordered), :backordered) if unit_backordered.positive?
72
+ package.add(FulfillmentItem.split(unit, unit_on_hand), :on_hand) if unit_on_hand.positive?
73
+ end
64
74
  else
65
- package.add unit
75
+ units.each { |unit| package.add unit }
66
76
  end
67
77
  end
68
78
 
@@ -84,8 +94,8 @@ module Spree
84
94
  # The units' own variants when they carry them, so a cart's variants are
85
95
  # read once however many locations are packed.
86
96
  def variants_for(units_by_variant_id)
87
- variants = units_by_variant_id.each_with_object({}) do |(variant_id, unit), loaded|
88
- loaded[variant_id] = unit.variant if unit.association(:variant).loaded?
97
+ variants = units_by_variant_id.each_with_object({}) do |(variant_id, units), loaded|
98
+ loaded[variant_id] = units.first.variant if units.first.association(:variant).loaded?
89
99
  end
90
100
  missing_ids = units_by_variant_id.keys - variants.keys
91
101
  missing_ids.empty? ? variants : variants.merge(Spree::Variant.where(id: missing_ids).index_by(&:id))
@@ -18,6 +18,7 @@ module Spree
18
18
 
19
19
  def prioritized_packages
20
20
  sort_packages
21
+ withdraw_covered_items
21
22
  adjust_packages
22
23
  prune_packages
23
24
  packages
@@ -44,11 +45,10 @@ module Spree
44
45
  end
45
46
 
46
47
  # Origins that can deliver go first, so the Adjuster fills each unit from
47
- # one of them and the origins that cannot are left empty and pruned. A
48
- # profile with per-region origin groups would otherwise allocate to
49
- # whichever warehouse came first, and when that warehouse's group has no
50
- # method for the destination the item is dropped as undeliverable even
51
- # though another warehouse could have shipped it.
48
+ # one of them. A profile with per-region origin groups would otherwise
49
+ # allocate to whichever warehouse came first, and when that warehouse's
50
+ # group has no method for the destination the item is dropped as
51
+ # undeliverable even though another warehouse could have shipped it.
52
52
  #
53
53
  # The caller's order survives within each group, so routing rank and the
54
54
  # store's location order still decide between origins that can both
@@ -56,25 +56,40 @@ module Spree
56
56
  def sort_packages
57
57
  return if estimator.nil?
58
58
 
59
- ranked = packages.each_with_index.sort_by do |package, index|
60
- [estimator.deliverable?(package) ? 0 : 1, index]
59
+ deliverable, @undeliverable_packages = packages.partition { |package| estimator.deliverable?(package) }
60
+ packages.replace(deliverable + @undeliverable_packages)
61
+ end
62
+
63
+ # Ordering alone loses to the Adjuster, which takes stock on hand anywhere
64
+ # over a backorder; a unit the capable origins cannot cover stays, so it is
65
+ # still reported as undeliverable rather than lost.
66
+ def withdraw_covered_items
67
+ return if @undeliverable_packages.blank? || @undeliverable_packages.size == packages.size
68
+
69
+ supplied = supplied_quantities(packages - @undeliverable_packages)
70
+
71
+ @undeliverable_packages.each do |package|
72
+ package.contents.each do |item|
73
+ package.remove_item(item) if supplied[hash_item(item)] >= item.inventory_unit.required_quantity
74
+ end
61
75
  end
76
+ end
62
77
 
63
- packages.replace(ranked.map(&:first))
78
+ def supplied_quantities(packages)
79
+ packages.flat_map(&:contents).each_with_object(Hash.new(0)) do |item, supplied|
80
+ supplied[hash_item(item)] += item.quantity
81
+ end
64
82
  end
65
83
 
66
84
  def prune_packages
67
85
  packages.reject!(&:empty?)
68
86
  end
69
87
 
88
+ # A proposed unit has no fulfillment, so its line item keeps two lines of
89
+ # one variant apart; a pair, as XORed ids collide whenever they match.
70
90
  def hash_item(item)
71
- shipment = item.inventory_unit.fulfillment
72
- variant = item.inventory_unit.variant
73
- if shipment.present?
74
- variant.hash ^ shipment.hash
75
- else
76
- variant.hash
77
- end
91
+ unit = item.inventory_unit
92
+ [unit.variant, unit.fulfillment || unit.line_item_id]
78
93
  end
79
94
  end
80
95
  end
@@ -42,6 +42,11 @@ module Spree
42
42
  acts_as_paranoid
43
43
  # deleted translation values still need to be accessible - remove deleted_at scope
44
44
  default_scope { unscope(where: :deleted_at) }
45
+
46
+ # Mobility deletes a row once its last value is cleared. The soft delete
47
+ # stamps only `deleted_at`, and the row stays readable, so the cleared
48
+ # values have to be written too or the old translation keeps showing.
49
+ before_destroy { update_columns(changes_to_save.transform_values(&:last)) if has_changes_to_save? }
45
50
  end
46
51
 
47
52
  #
@@ -50,8 +55,9 @@ module Spree
50
55
  UNIT_SYSTEMS = %w[metric imperial].freeze
51
56
 
52
57
  # general preferences
53
- preference :admin_locale, :string, format: :locale
54
- preference :timezone, :string, format: :timezone, default: Time.zone.name
58
+ preference :admin_locale, :string, format: :locale, parse_on_set: ->(value) { Spree::Locales.listed(value) || value.presence }
59
+ preference :timezone, :string, format: :timezone, default: Spree::TimeZones.normalize(Time.zone.name),
60
+ parse_on_set: ->(value) { Spree::TimeZones.normalize(value) }
55
61
  preference :weight_unit, :string, default: 'lb', choices: -> { Spree::Variant::WEIGHT_UNITS }
56
62
  preference :unit_system, :string, default: 'imperial', choices: -> { UNIT_SYSTEMS }
57
63
  # Anonymous identifier of this installation, kept on the default store
@@ -403,6 +409,8 @@ module Spree
403
409
  message: :invalid_document_number_affix }
404
410
  validates :preferred_capture_method, inclusion: { in: Spree::CaptureMethod::CAPTURE_METHODS }
405
411
  validate :preferred_storefront_url_is_an_origin
412
+ validate :preferred_admin_locale_is_listed
413
+ validate :preferred_timezone_is_a_time_zone
406
414
  validate :order_number_sequence_start_unchanged_after_first_number, on: :update
407
415
  validates :mail_from_address, email: { allow_blank: false }
408
416
  validates :customer_support_email, email: { allow_blank: true }
@@ -836,6 +844,31 @@ module Spree
836
844
  errors.add(:preferred_storefront_url, :invalid)
837
845
  end
838
846
 
847
+ def preferred_admin_locale_is_listed
848
+ locale = preferred_admin_locale
849
+ return if locale.blank? || Spree::Locales::ALL.include?(locale)
850
+ return if awaiting_upgrade?(:admin_locale) { Spree::Locales.listed(locale) }
851
+
852
+ errors.add(:preferred_admin_locale, :inclusion)
853
+ end
854
+
855
+ def preferred_timezone_is_a_time_zone
856
+ return if Spree::TimeZones.valid?(preferred_timezone)
857
+ return if awaiting_upgrade?(:timezone) { Spree::TimeZones.valid?(Spree::TimeZones.normalize(preferred_timezone)) }
858
+
859
+ errors.add(:preferred_timezone, :invalid)
860
+ end
861
+
862
+ # A value saved before 6.0 that the store settings upgrade step converts
863
+ # must not block saving the store's other settings until the step runs.
864
+ # The block says whether the step can convert it.
865
+ def awaiting_upgrade?(name)
866
+ return false unless persisted?
867
+ return false unless public_send(:"preferred_#{name}") == (attribute_was('preferences') || {})[name]
868
+
869
+ yield.present?
870
+ end
871
+
839
872
  # The starting value only shapes a counter that does not exist yet — once
840
873
  # the first number is issued the counter owns the value, so accepting a
841
874
  # change here would persist a setting that can never apply again. The
@@ -252,12 +252,13 @@ module Spree
252
252
 
253
253
  # Three-way dispatch on the cart→checkout transition:
254
254
  # entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
255
+ # A new destination reserves again, because it can move the units to another origin.
255
256
  # A failed Reserve raises so the enclosing transaction rolls back and the
256
257
  # outer rescue surfaces the error to the API caller.
257
258
  def sync_stock_reservations(was_in_checkout:)
258
259
  if !cart.in_checkout?
259
260
  Spree::StockReservations::Release.call(cart: cart) if was_in_checkout
260
- elsif was_in_checkout
261
+ elsif was_in_checkout && !@destination_changed && !@new_delivery_destination
261
262
  Spree::StockReservations::Extend.call(cart: cart)
262
263
  else
263
264
  result = Spree::StockReservations::Reserve.call(cart: cart)
@@ -14,13 +14,17 @@ module Spree
14
14
 
15
15
  ApplicationRecord.transaction do
16
16
  targets = build_targets(cart)
17
- break if targets.empty?
18
17
 
19
18
  # Pessimistic lock + fresh read of count_on_hand. The lock serializes
20
19
  # concurrent checkouts and we use the locked rows below so we never
21
- # check stock against a stale association cache.
20
+ # check stock against a stale association cache. The shelves held
21
+ # now are locked with the new ones, in id order, so two checkouts
22
+ # moving holds past each other cannot deadlock.
23
+ stock_level_ids = targets.map { |_, si, _| si.id } |
24
+ Spree::StockReservation.for_order(cart).pluck(:stock_level_id)
22
25
  locked_stock_levels = Spree::StockLevel
23
- .where(id: targets.map { |_, si| si.id })
26
+ .where(id: stock_level_ids)
27
+ .order(:id)
24
28
  .lock
25
29
  .index_by(&:id)
26
30
 
@@ -29,22 +33,13 @@ module Spree
29
33
 
30
34
  this_order_used = Hash.new(0)
31
35
 
32
- targets.each do |line_item, stock_level|
36
+ reservations = targets.map do |line_item, stock_level, quantity|
33
37
  stock_level = locked_stock_levels.fetch(stock_level.id)
34
38
  available = stock_level.available_count - held.fetch(stock_level.id, 0) - this_order_used[stock_level.id]
35
39
 
36
- if available < line_item.quantity
37
- raise InsufficientStockError.new(
38
- line_item,
39
- I18n.t(
40
- 'spree.insufficient_stock_for_reservation',
41
- item: line_item.variant.name,
42
- available: [available, 0].max
43
- )
44
- )
45
- end
40
+ raise_insufficient_stock(line_item, available) if available < quantity
46
41
 
47
- this_order_used[stock_level.id] += line_item.quantity
42
+ this_order_used[stock_level.id] += quantity
48
43
 
49
44
  reservation = existing[[stock_level.id, line_item.id]] ||
50
45
  Spree::StockReservation.new(stock_level: stock_level, line_item: line_item)
@@ -55,10 +50,15 @@ module Spree
55
50
  reservation.order = cart
56
51
  reservation.cart = nil
57
52
  end
58
- reservation.quantity = line_item.quantity
53
+ reservation.quantity = quantity
59
54
  reservation.expires_at = expires_at
60
55
  reservation.save!
56
+ reservation
61
57
  end
58
+
59
+ # Left in place, a hold the allocation moved away from blocks other buyers until it expires.
60
+ moved = Spree::StockReservation.for_order(cart).where.not(id: reservations.map(&:id))
61
+ Spree::StockReservation.withdraw(moved)
62
62
  end
63
63
 
64
64
  success(cart)
@@ -68,23 +68,71 @@ module Spree
68
68
 
69
69
  private
70
70
 
71
+ # Held where the allocation packs each unit, so the hold protects the stock the order will ship.
71
72
  def build_targets(cart)
72
- cart.line_items.includes(variant: [:product, { stock_levels: :stock_location }]).filter_map do |line_item|
73
+ cart.line_items.reset
74
+ coordinator = Spree::Stock::Coordinator.new(cart)
75
+ packages = coordinator.allocated_packages
76
+ check_allocated_in_full(cart, packages_items(packages))
77
+
78
+ # Once the address is known, a unit only an origin unable to reach it
79
+ # would pack cannot ship, so it is left to the delivery check rather
80
+ # than held where it blocks buyers that origin does serve.
81
+ packages = packages.select { |package| coordinator.deliverable?(package) } if cart.ship_address.present?
82
+ items = packages_items(packages)
83
+ stock_levels = Spree::Stock::Packer.stock_levels_by_location(packages.map(&:stock_location).uniq,
84
+ cart.line_items.map(&:variant_id).uniq)
85
+
86
+ # Summed because a splitter can pack one line into several packages from one location.
87
+ quantities = Hash.new(0)
88
+ items.each do |stock_location, item|
89
+ next unless item.on_hand?
90
+
91
+ line_item = item.inventory_unit.line_item
73
92
  variant = line_item.variant
74
- next unless variant&.should_track_inventory?
75
-
76
- stock_level = select_stock_level(variant)
93
+ stock_level = stock_levels.dig(stock_location.id, variant.id)
77
94
  # Backorderable and pre-cart variants oversell past count_on_hand,
78
95
  # so a reservation capped at on-hand stock would wrongly reject them;
79
96
  # their cap is enforced by Stock::Quantifier#can_supply? instead.
97
+ next unless variant.should_track_inventory?
80
98
  next if stock_level.nil? || stock_level.backorderable? || variant.preorder?
81
99
 
82
- [line_item, stock_level]
100
+ quantities[[line_item, stock_level]] += item.quantity
101
+ end
102
+
103
+ quantities.map { |(line_item, stock_level), quantity| [line_item, stock_level, quantity] }
104
+ end
105
+
106
+ def packages_items(packages)
107
+ packages.flat_map { |package| package.contents.map { |item| [package.stock_location, item] } }
108
+ end
109
+
110
+ # A line placed nowhere is left to the delivery check, which can say why;
111
+ # one placed in part has shelves that fall short, and is refused.
112
+ def check_allocated_in_full(cart, items)
113
+ allocated = items.each_with_object(Hash.new(0)) do |(_, item), quantities|
114
+ quantities[item.inventory_unit.line_item_id] += item.quantity
115
+ end
116
+
117
+ cart.line_items.each do |line_item|
118
+ variant = line_item.variant
119
+ next unless variant&.should_track_inventory?
120
+ next if variant.preorder?
121
+
122
+ placed = allocated[line_item.id]
123
+ raise_insufficient_stock(line_item, placed) if placed.positive? && placed < line_item.quantity
83
124
  end
84
125
  end
85
126
 
86
- def select_stock_level(variant)
87
- variant.stock_levels.detect { |si| si.stock_location&.active? && si.available? }
127
+ def raise_insufficient_stock(line_item, available)
128
+ raise InsufficientStockError.new(
129
+ line_item,
130
+ I18n.t(
131
+ 'spree.insufficient_stock_for_reservation',
132
+ item: line_item.variant.name,
133
+ available: [available, 0].max
134
+ )
135
+ )
88
136
  end
89
137
 
90
138
  def held_by_others(stock_level_ids, owner)
@@ -104,8 +152,8 @@ module Spree
104
152
  def existing_reservations_for(targets)
105
153
  return {} if targets.empty?
106
154
 
107
- stock_level_ids = targets.map { |_, si| si.id }
108
- line_item_ids = targets.map { |li, _| li.id }
155
+ stock_level_ids = targets.map { |_, si, _| si.id }
156
+ line_item_ids = targets.map { |li, _, _| li.id }
109
157
 
110
158
  Spree::StockReservation
111
159
  .where(stock_level_id: stock_level_ids, line_item_id: line_item_ids)
@@ -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
@@ -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
@@ -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.1'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -666,6 +666,7 @@ require 'spree/i18n'
666
666
  require 'spree/iso_data'
667
667
  require 'spree/localized_number'
668
668
  require 'spree/measurement'
669
+ require 'spree/time_zones'
669
670
  require 'spree/translations'
670
671
  require 'spree/money'
671
672
  require 'spree/service_module'
@@ -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
@@ -597,6 +597,19 @@ steps:
597
597
  is dropped in 6.1. Idempotent — only rows without a capture_method are
598
598
  written.
599
599
 
600
+ - id: store_regional_settings
601
+ name: "Convert store time zones and admin languages to standard codes"
602
+ task_class: "Spree::MaintenanceTasks::Upgrade::StoreRegionalSettings"
603
+ notes: |
604
+ A store's time zone is now always an IANA name such as America/New_York,
605
+ and its admin language a locale code Spree lists, the forms the API
606
+ accepts and browsers understand. Rails time zone names such as
607
+ "Eastern Time (US & Canada)" and locale spellings such as en_US are
608
+ converted. A time zone that names no zone is reset to the store default,
609
+ which is what the store already ran on; an admin language Spree does not
610
+ list is cleared, so staff see their own language. Idempotent — stores
611
+ already on standard codes are skipped.
612
+
600
613
  - id: product_types_backfill
601
614
  name: "Backfill product type store ownership"
602
615
  task: "spree:product_types:backfill"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 6.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-10-27 00:00:00.000000000 Z
13
+ date: 2026-10-29 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -785,6 +785,7 @@ files:
785
785
  - app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb
786
786
  - app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb
787
787
  - app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb
788
+ - app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb
788
789
  - app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb
789
790
  - app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb
790
791
  - app/models/concerns/spree/account_deactivation.rb
@@ -2027,6 +2028,7 @@ files:
2027
2028
  - db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb
2028
2029
  - db/migrate/20261022000001_store_credit_actor_polymorphic.rb
2029
2030
  - db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb
2031
+ - db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb
2030
2032
  - db/sample_data/channels.rb
2031
2033
  - db/sample_data/collections.rb
2032
2034
  - db/sample_data/custom_field_definitions.rb
@@ -2312,6 +2314,7 @@ files:
2312
2314
  - lib/spree/testing_support/store.rb
2313
2315
  - lib/spree/testing_support/tax_identifier_validators.rb
2314
2316
  - lib/spree/testing_support/url_helpers.rb
2317
+ - lib/spree/time_zones.rb
2315
2318
  - lib/spree/translations.rb
2316
2319
  - lib/spree/upgrade.rb
2317
2320
  - lib/spree/upgrades/5_4_to_5_5/manifest.yml
@@ -2397,9 +2400,9 @@ licenses:
2397
2400
  - BSD-3-Clause
2398
2401
  metadata:
2399
2402
  bug_tracker_uri: https://github.com/spree/spree/issues
2400
- changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.0
2403
+ changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.1
2401
2404
  documentation_uri: https://docs.spreecommerce.org/
2402
- source_code_uri: https://github.com/spree/spree/tree/v6.0.0
2405
+ source_code_uri: https://github.com/spree/spree/tree/v6.0.1
2403
2406
  post_install_message:
2404
2407
  rdoc_options: []
2405
2408
  require_paths: