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
@@ -6,12 +6,19 @@ module Spree
6
6
 
7
7
  has_secure_token :token
8
8
 
9
+ # Personal data. Rows written before encryption was enabled stay readable
10
+ # and are encrypted when the token next rotates.
11
+ encrypts :ip_address, support_unencrypted_data: true if ActiveRecord::Encryption.config.has_primary_key?
12
+
9
13
  validates :user, :expires_at, presence: true
10
14
  # On create only: rows minted before the column exists keep their NULL and
11
15
  # stay loadable (they are simply unredeemable), while nothing new is born
12
16
  # without the surface it belongs to.
13
17
  validates :audience, presence: true, on: :create
14
18
 
19
+ # Starting a session and refreshing one both create a row.
20
+ after_create :record_user_activity
21
+
15
22
  scope :active, -> { where('expires_at > ?', Time.current) }
16
23
  scope :expired, -> { where('expires_at <= ?', Time.current) }
17
24
 
@@ -23,6 +30,12 @@ module Spree
23
30
  expires_at <= Time.current
24
31
  end
25
32
 
33
+ # Each refresh replaces the row, so its creation is the session's last use.
34
+ # @return [ActiveSupport::TimeWithZone]
35
+ def last_active_at
36
+ created_at
37
+ end
38
+
26
39
  # Rotate: destroy this token and create a new one.
27
40
  # Returns the new token.
28
41
  def rotate!(request_env: {})
@@ -74,5 +87,18 @@ module Spree
74
87
  rescue StandardError
75
88
  30.days
76
89
  end
90
+
91
+ private
92
+
93
+ # Outlives the session itself, which signing out deletes. Skips callbacks
94
+ # and +updated_at+: activity is not an edit to the account, and an older
95
+ # session committing last never moves it back. Users without the column
96
+ # (customers, custom admin classes) are left alone.
97
+ def record_user_activity
98
+ return unless user.has_attribute?(:last_active_at)
99
+
100
+ stale = user.class.where(id: user.id).where('last_active_at IS NULL OR last_active_at < ?', created_at)
101
+ stale.update_all(last_active_at: created_at)
102
+ end
77
103
  end
78
104
  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
@@ -71,7 +71,7 @@ module Spree
71
71
  has_many :stock_reservations
72
72
  end
73
73
 
74
- has_many :option_value_variants, class_name: 'Spree::OptionValueVariant'
74
+ has_many :option_value_variants, class_name: 'Spree::OptionValueVariant', dependent: :destroy
75
75
  has_many :option_values, through: :option_value_variants, dependent: :destroy, class_name: 'Spree::OptionValue'
76
76
 
77
77
  has_many :images, -> { order(:position) }, as: :viewable, dependent: :destroy, class_name: 'Spree::Media'
@@ -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)
@@ -0,0 +1,72 @@
1
+ module Spree
2
+ module Promotions
3
+ # Removes a promotion from a cart or an order the way a shopper removing the
4
+ # code does: the order promotion, held coupon code, discounts and items
5
+ # the promotion added are removed, then totals are recalculated.
6
+ #
7
+ # @example Removing a promotion from a cart
8
+ # Spree::Promotions::DetachFromOwner.call(owner: cart, promotion: promotion)
9
+ class DetachFromOwner
10
+ prepend Spree::ServiceModule::Base
11
+
12
+ # Action types whose line items are removed along with the promotion.
13
+ CREATE_LINE_ITEMS_ACTION_TYPES = %w[Spree::Promotion::Actions::CreateLineItems].freeze
14
+
15
+ # @param owner [Spree::Cart, Spree::Order] the cart or order holding the promotion
16
+ # @param promotion [Spree::Promotion] the promotion to remove
17
+ # @param clear_coupon_code [Boolean] also clear the code the shopper entered on the
18
+ # owner, so recalculating does not apply the promotion again. Leave it off when the
19
+ # shopper should be told the code was lost.
20
+ # @return [Spree::ServiceModule::Result] success, holding the owner; a failure when the
21
+ # owner is already completed, since placed orders are never changed this way
22
+ def call(owner:, promotion:, clear_coupon_code: false)
23
+ return failure(owner, 'Cannot remove a promotion from a completed order') if owner.completed?
24
+
25
+ held_code = promotion.coupon_codes.held_by(owner).first if promotion.multi_codes?
26
+ clear_entered_coupon_code(owner, held_code&.code || promotion.code) if clear_coupon_code
27
+
28
+ # The order promotion has to go before line items are removed
29
+ owner.promotions.delete(promotion)
30
+
31
+ if held_code
32
+ held_code.remove_from_order
33
+ else
34
+ promotion.touch
35
+ end
36
+
37
+ owner.discounts.where(promotion_action_id: promotion.actions.ids).destroy_all
38
+ remove_promotion_line_items(owner, promotion)
39
+ owner.recalculate_totals!
40
+
41
+ success(owner)
42
+ end
43
+
44
+ private
45
+
46
+ # Carts keep the entered code, and recalculation would apply it again.
47
+ # Callers that let the shopper be told the code went keep it.
48
+ def clear_entered_coupon_code(owner, code)
49
+ return if code.blank? || !owner.has_attribute?(:coupon_code)
50
+ return unless owner.read_attribute(:coupon_code).to_s.casecmp?(code)
51
+
52
+ owner.update_column(:coupon_code, nil)
53
+ end
54
+
55
+ # Removes the line items a promotion's create-line-items actions added.
56
+ def remove_promotion_line_items(owner, promotion)
57
+ action_ids = promotion.actions.where(type: CREATE_LINE_ITEMS_ACTION_TYPES).ids
58
+ remove_service = owner.is_a?(Spree::Cart) ? Spree.cart_remove_item_service : Spree.order_remove_item_service
59
+ owner_key = owner.is_a?(Spree::Cart) ? :cart : :order
60
+
61
+ Spree::PromotionActionLineItem.where(promotion_action: action_ids).find_each do |item|
62
+ next if owner.find_line_item_by_variant(item.variant).blank?
63
+
64
+ # The promotion is still active here, so recalculating would add its items back;
65
+ # other promotions are still re-evaluated.
66
+ options = owner.is_a?(Spree::Cart) ? { excluded_promotion_ids: [promotion.id] } : {}
67
+ remove_service.call(owner_key => owner, variant: item.variant, quantity: item.quantity, options: options)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -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,17 @@ module Spree
29
33
 
30
34
  this_order_used = Hash.new(0)
31
35
 
32
- targets.each do |line_item, stock_level|
33
- stock_level = locked_stock_levels.fetch(stock_level.id)
36
+ reservations = targets.filter_map do |line_item, stock_level, quantity|
37
+ # A shelf deleted since the allocation was read (a variant being
38
+ # discontinued removes its stock) has nothing left to hold.
39
+ stock_level = locked_stock_levels[stock_level.id]
40
+ next if stock_level.nil?
41
+
34
42
  available = stock_level.available_count - held.fetch(stock_level.id, 0) - this_order_used[stock_level.id]
35
43
 
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
44
+ raise_insufficient_stock(line_item, available) if available < quantity
46
45
 
47
- this_order_used[stock_level.id] += line_item.quantity
46
+ this_order_used[stock_level.id] += quantity
48
47
 
49
48
  reservation = existing[[stock_level.id, line_item.id]] ||
50
49
  Spree::StockReservation.new(stock_level: stock_level, line_item: line_item)
@@ -55,10 +54,15 @@ module Spree
55
54
  reservation.order = cart
56
55
  reservation.cart = nil
57
56
  end
58
- reservation.quantity = line_item.quantity
57
+ reservation.quantity = quantity
59
58
  reservation.expires_at = expires_at
60
59
  reservation.save!
60
+ reservation
61
61
  end
62
+
63
+ # Left in place, a hold the allocation moved away from blocks other buyers until it expires.
64
+ moved = Spree::StockReservation.for_order(cart).where.not(id: reservations.map(&:id))
65
+ Spree::StockReservation.withdraw(moved)
62
66
  end
63
67
 
64
68
  success(cart)
@@ -68,23 +72,71 @@ module Spree
68
72
 
69
73
  private
70
74
 
75
+ # Held where the allocation packs each unit, so the hold protects the stock the order will ship.
71
76
  def build_targets(cart)
72
- cart.line_items.includes(variant: [:product, { stock_levels: :stock_location }]).filter_map do |line_item|
77
+ cart.line_items.reset
78
+ coordinator = Spree::Stock::Coordinator.new(cart)
79
+ packages = coordinator.allocated_packages
80
+ check_allocated_in_full(cart, packages_items(packages))
81
+
82
+ # Once the address is known, a unit only an origin unable to reach it
83
+ # would pack cannot ship, so it is left to the delivery check rather
84
+ # than held where it blocks buyers that origin does serve.
85
+ packages = packages.select { |package| coordinator.deliverable?(package) } if cart.ship_address.present?
86
+ items = packages_items(packages)
87
+ stock_levels = Spree::Stock::Packer.stock_levels_by_location(packages.map(&:stock_location).uniq,
88
+ cart.line_items.map(&:variant_id).uniq)
89
+
90
+ # Summed because a splitter can pack one line into several packages from one location.
91
+ quantities = Hash.new(0)
92
+ items.each do |stock_location, item|
93
+ next unless item.on_hand?
94
+
95
+ line_item = item.inventory_unit.line_item
73
96
  variant = line_item.variant
74
- next unless variant&.should_track_inventory?
75
-
76
- stock_level = select_stock_level(variant)
97
+ stock_level = stock_levels.dig(stock_location.id, variant.id)
77
98
  # Backorderable and pre-cart variants oversell past count_on_hand,
78
99
  # so a reservation capped at on-hand stock would wrongly reject them;
79
100
  # their cap is enforced by Stock::Quantifier#can_supply? instead.
101
+ next unless variant.should_track_inventory?
80
102
  next if stock_level.nil? || stock_level.backorderable? || variant.preorder?
81
103
 
82
- [line_item, stock_level]
104
+ quantities[[line_item, stock_level]] += item.quantity
105
+ end
106
+
107
+ quantities.map { |(line_item, stock_level), quantity| [line_item, stock_level, quantity] }
108
+ end
109
+
110
+ def packages_items(packages)
111
+ packages.flat_map { |package| package.contents.map { |item| [package.stock_location, item] } }
112
+ end
113
+
114
+ # A line placed nowhere is left to the delivery check, which can say why;
115
+ # one placed in part has shelves that fall short, and is refused.
116
+ def check_allocated_in_full(cart, items)
117
+ allocated = items.each_with_object(Hash.new(0)) do |(_, item), quantities|
118
+ quantities[item.inventory_unit.line_item_id] += item.quantity
119
+ end
120
+
121
+ cart.line_items.each do |line_item|
122
+ variant = line_item.variant
123
+ next unless variant&.should_track_inventory?
124
+ next if variant.preorder?
125
+
126
+ placed = allocated[line_item.id]
127
+ raise_insufficient_stock(line_item, placed) if placed.positive? && placed < line_item.quantity
83
128
  end
84
129
  end
85
130
 
86
- def select_stock_level(variant)
87
- variant.stock_levels.detect { |si| si.stock_location&.active? && si.available? }
131
+ def raise_insufficient_stock(line_item, available)
132
+ raise InsufficientStockError.new(
133
+ line_item,
134
+ I18n.t(
135
+ 'spree.insufficient_stock_for_reservation',
136
+ item: line_item.variant.name,
137
+ available: [available, 0].max
138
+ )
139
+ )
88
140
  end
89
141
 
90
142
  def held_by_others(stock_level_ids, owner)
@@ -104,8 +156,8 @@ module Spree
104
156
  def existing_reservations_for(targets)
105
157
  return {} if targets.empty?
106
158
 
107
- stock_level_ids = targets.map { |_, si| si.id }
108
- line_item_ids = targets.map { |li, _| li.id }
159
+ stock_level_ids = targets.map { |_, si, _| si.id }
160
+ line_item_ids = targets.map { |li, _, _| li.id }
109
161
 
110
162
  Spree::StockReservation
111
163
  .where(stock_level_id: stock_level_ids, line_item_id: line_item_ids)
@@ -9,6 +9,8 @@ module Spree
9
9
 
10
10
  # @param cart [Spree::Cart, Spree::Order] draft orders ride the same pipeline
11
11
  # @param line_item [Spree::LineItem, nil] the item whose change triggered this
12
+ # @param options [Hash] +excluded_promotion_ids:+ lists promotions to leave out of activation,
13
+ # for callers that are removing a promotion's effects
12
14
  def perform(cart: nil, order: nil, line_item: nil, line_item_created: false, options: {})
13
15
  if order
14
16
  Spree::Deprecation.warn('Calling Spree::Carts::Recalculate with order: is deprecated and will be removed in Spree 6.1. Pass cart: instead.')
@@ -47,7 +49,9 @@ module Spree
47
49
  def activate_promotions
48
50
  @promotion_context = run_hooks :set_promotion_context
49
51
 
50
- ::Spree::PromotionHandler::Cart.new(cart, line_item).activate
52
+ ::Spree::PromotionHandler::Cart.new(
53
+ cart, line_item, excluded_promotion_ids: options[:excluded_promotion_ids] || []
54
+ ).activate
51
55
  end
52
56
  end
53
57
  end