spree_core 5.4.0.rc2 → 5.4.0.rc3

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: 5de0595dccb2046acb60805390c641e66c9e87c070c25df2587d5a8eaf9bc358
4
- data.tar.gz: 6cd99e1b81481dd826565d1f579ea64965dfa04a808905f830505110f330473d
3
+ metadata.gz: feb2c702a17f0e10532d677c7c6cdb4a4092476ce33966db067ff095e0fd3d51
4
+ data.tar.gz: 6cfb1613127976496083e9d11d5a073e20c7d94b139d2df2f621e4c18ae52936
5
5
  SHA512:
6
- metadata.gz: 1d27ab08a797c2f5419f36bd3ad368f1cecaf6610d1ca9289e9b5dea81f62a4856b8b767b23129b1dd20e0e0fd0c02f619581e58b7b347642cc801a2808de453
7
- data.tar.gz: d551a808c3d989cf88e02c18b00bd9582b7672459b59e9c0ea281b6797cf5b0b95b698e32f98adc7a180723ab98e84aae99bc163ac5ee7de7a3d6745abc97e80
6
+ metadata.gz: 8abff3fa43c5226f1eb1c3dd1a5d1bd84fedfd9ef62895da5df6402df1bd9e0fcb68a9fd646790d90ea5efa04292330aeab50e9269d1352a8970c2c8ff362d39
7
+ data.tar.gz: 2cde221d1575d9d48c633d4aacdc588d775aff1445c0bdc5c895ff82e77444e3a5341d5fc1677d6c5013accd73f182f1e2ea5bb5b9532470d13dae4a7e316061
@@ -3,6 +3,16 @@ module Spree
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
+ cattr_accessor :search_scopes do
7
+ []
8
+ end
9
+
10
+ def self.add_search_scope(name, &block)
11
+ Spree::Deprecation.warn("add_search_scope is deprecated and will be removed in Spree 6.0. Use scope :#{name}, &block instead or use method instead")
12
+ singleton_class.send(:define_method, name.to_sym, &block)
13
+ search_scopes << name.to_sym
14
+ end
15
+
6
16
  scope :ascend_by_updated_at, -> { order("#{Product.quoted_table_name}.updated_at ASC") }
7
17
  scope :descend_by_updated_at, -> { order("#{Product.quoted_table_name}.updated_at DESC") }
8
18
  scope :ascend_by_name, -> { order("#{Product.quoted_table_name}.name ASC") }
@@ -34,11 +34,31 @@ module Spree
34
34
  Spree.gift_card_remove_service.call(order: self)
35
35
  end
36
36
 
37
+ # Recalculates the gift card payment amount based on the current order total.
38
+ # Updates the existing payment in place instead of remove + re-apply
39
+ # to avoid creating unnecessary invalid payment records.
37
40
  def recalculate_gift_card
38
- applied_gift_card = gift_card
41
+ return unless gift_card.present?
42
+
43
+ payment = payments.checkout.store_credits.where(source: gift_card.store_credits).first
44
+ return unless payment
45
+
46
+ # with_lock acquires a row lock and wraps in a transaction.
47
+ # The entire read-compute-write must be inside the lock to prevent
48
+ # stale amount_remaining from concurrent requests.
49
+ gift_card.with_lock do
50
+ new_amount = [gift_card.amount_remaining + payment.amount, total].min
51
+ next if payment.amount == new_amount
39
52
 
40
- remove_gift_card
41
- apply_gift_card(applied_gift_card)
53
+ difference = new_amount - payment.amount
54
+ # Uses update_column to bypass Payment#max_amount validation which
55
+ # can fail during recalculation due to stale in-memory order state.
56
+ # Bounds are enforced via min() above.
57
+ payment.update_column(:amount, new_amount)
58
+ payment.source.update_column(:amount, new_amount)
59
+ gift_card.amount_used += difference
60
+ gift_card.save!
61
+ end
42
62
  end
43
63
 
44
64
  def redeem_gift_card
@@ -49,10 +49,27 @@ module Spree
49
49
  :included_tax_total, :additional_tax_total, :tax_total,
50
50
  :shipment_total, :promo_total, :total,
51
51
  :cart_promo_total, :pre_tax_item_amount, :pre_tax_total,
52
- :payment_total
52
+ :payment_total, :amount_due
53
53
 
54
54
  alias display_ship_total display_shipment_total
55
55
  alias_attribute :ship_total, :shipment_total
56
+ def amount_due
57
+ outstanding_balance
58
+ end
59
+
60
+ # Transient warnings populated by remove_out_of_stock_items!
61
+ attribute :warnings, default: -> { [] }
62
+
63
+ # Removes out-of-stock/discontinued items and populates warnings.
64
+ # Returns self (reloaded if items were removed) with warnings set.
65
+ def remove_out_of_stock_items!
66
+ result = Spree::Cart::RemoveOutOfStockItems.call(order: self)
67
+ return self unless result.success?
68
+
69
+ order, _messages, warnings = result.value
70
+ order.warnings = warnings || []
71
+ order
72
+ end
56
73
 
57
74
  # 5.5 API naming bridges (DB columns rename in 6.0)
58
75
  alias_attribute :discount_total, :promo_total
@@ -13,8 +13,11 @@ module Spree
13
13
  belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant', inverse_of: :prices, touch: true
14
14
  belongs_to :price_list, class_name: 'Spree::PriceList', optional: true
15
15
 
16
+ has_many :price_histories, class_name: 'Spree::PriceHistory', dependent: :delete_all
17
+
16
18
  before_validation :ensure_currency
17
19
  before_save :remove_compare_at_amount_if_equals_amount
20
+ after_save :record_price_history, if: :should_record_price_history?
18
21
 
19
22
  # legacy behavior
20
23
  validates :amount, allow_nil: true, numericality: {
@@ -156,8 +159,34 @@ module Spree
156
159
  !zero?
157
160
  end
158
161
 
162
+ # Returns the price history record with the lowest amount in the last 30 days
163
+ # Used for EU Omnibus Directive compliance
164
+ #
165
+ # @return [Spree::PriceHistory, nil]
166
+ def prior_price
167
+ price_histories.where(recorded_at: 30.days.ago..).order(:amount).first
168
+ end
169
+
159
170
  private
160
171
 
172
+ def should_record_price_history?
173
+ price_list_id.nil? &&
174
+ amount.present? &&
175
+ saved_change_to_amount? &&
176
+ Spree::Config[:track_price_history]
177
+ end
178
+
179
+ def record_price_history
180
+ Spree::PriceHistory.create!(
181
+ price: self,
182
+ variant_id: variant_id,
183
+ amount: amount,
184
+ compare_at_amount: compare_at_amount,
185
+ currency: currency,
186
+ recorded_at: Time.current
187
+ )
188
+ end
189
+
161
190
  def ensure_currency
162
191
  self.currency ||= Spree::Store.default.default_currency
163
192
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PriceHistory < Spree.base_class
5
+ belongs_to :price, class_name: 'Spree::Price'
6
+ belongs_to :variant, class_name: 'Spree::Variant'
7
+
8
+ validates :amount, presence: true, numericality: { greater_than_or_equal_to: 0 }
9
+ validates :currency, presence: true
10
+ validates :recorded_at, presence: true
11
+
12
+ scope :for_variant, ->(variant_id) { where(variant_id: variant_id) }
13
+ scope :for_currency, ->(currency) { where(currency: currency) }
14
+ scope :in_period, ->(from, to = Time.current) { where(recorded_at: from..to) }
15
+ scope :recent, ->(days = 30) { in_period(days.days.ago) }
16
+
17
+ def money
18
+ Spree::Money.new(amount || 0, currency: currency)
19
+ end
20
+
21
+ def display_amount
22
+ money.to_s
23
+ end
24
+
25
+ def amount_in_cents
26
+ money.amount_in_cents
27
+ end
28
+ end
29
+ end
@@ -11,7 +11,7 @@ module Spree
11
11
  end
12
12
 
13
13
  def apply
14
- if load_gift_card_code
14
+ if gift_cards_enabled? && load_gift_card_code
15
15
 
16
16
  if @gift_card.expired?
17
17
  set_error_code :gift_card_expired
@@ -47,7 +47,7 @@ module Spree
47
47
  end
48
48
 
49
49
  def remove(coupon_code)
50
- if order.gift_card
50
+ if gift_cards_enabled? && order.gift_card
51
51
  result = order.remove_gift_card
52
52
 
53
53
  if result.success?
@@ -212,6 +212,13 @@ module Spree
212
212
  Spree::CouponCode.unused.find_by(promotion_id: discount.source.promotion_id, code: coupon_code)&.apply_order!(order)
213
213
  end
214
214
 
215
+ # Whether the coupon handler should also handle gift card codes.
216
+ # Defaults to true for backwards compatibility. Pass enable_gift_cards: false
217
+ # when using the dedicated gift card endpoint.
218
+ def gift_cards_enabled?
219
+ options.fetch(:enable_gift_cards, true)
220
+ end
221
+
215
222
  def load_gift_card_code
216
223
  return unless order.coupon_code.present?
217
224
 
@@ -5,8 +5,9 @@ module Spree
5
5
 
6
6
  def call(order:)
7
7
  @messages = []
8
+ @warnings = []
8
9
 
9
- return success([order, @messages]) if order.item_count.zero? || order.line_items.none?
10
+ return success([order, @messages, @warnings]) if order.item_count.zero? || order.line_items.none?
10
11
 
11
12
  line_items = order.line_items.includes(variant: [:product, :stock_items, :stock_locations, { stock_items: :stock_location }])
12
13
 
@@ -17,9 +18,9 @@ module Spree
17
18
  end
18
19
 
19
20
  if @messages.any? # If any line item was removed, reload the order
20
- success([order.reload, @messages])
21
+ success([order.reload, @messages, @warnings])
21
22
  else
22
- success([order, @messages])
23
+ success([order, @messages, @warnings])
23
24
  end
24
25
  end
25
26
 
@@ -28,7 +29,14 @@ module Spree
28
29
  def valid_status?(line_item)
29
30
  product = line_item.product
30
31
  if !product.active? || product.deleted? || product.discontinued? || line_item.variant.discontinued?
31
- @messages << Spree.t('cart_line_item.discontinued', li_name: line_item.name)
32
+ message = Spree.t('cart_line_item.discontinued', li_name: line_item.name)
33
+ @messages << message
34
+ @warnings << {
35
+ code: 'line_item_removed',
36
+ message: message,
37
+ line_item_id: line_item.prefixed_id,
38
+ variant_id: line_item.variant.prefixed_id
39
+ }
32
40
  return false
33
41
  end
34
42
  true
@@ -36,7 +44,14 @@ module Spree
36
44
 
37
45
  def stock_available?(line_item)
38
46
  if line_item.insufficient_stock?
39
- @messages << Spree.t('cart_line_item.out_of_stock', li_name: line_item.name)
47
+ message = Spree.t('cart_line_item.out_of_stock', li_name: line_item.name)
48
+ @messages << message
49
+ @warnings << {
50
+ code: 'line_item_removed',
51
+ message: message,
52
+ line_item_id: line_item.prefixed_id,
53
+ variant_id: line_item.variant.prefixed_id
54
+ }
40
55
  return false
41
56
  end
42
57
  true
@@ -89,18 +89,20 @@ module Spree
89
89
  cart.state = 'address'
90
90
  end
91
91
 
92
- # Auto-advance as far as the checkout state machine allows.
93
- # Loops cart.next until the cart can't progress further (e.g. missing
94
- # payment) or reaches confirm/complete. Stops at the first step whose
95
- # before_transition guard fails the `requirements` array in the
96
- # serialized response tells the frontend what's still missing.
97
- # Failure is swallowed — the update itself already succeeded.
92
+ # Auto-advance as far as the checkout state machine allows, but never
93
+ # to complete. The complete transition must always be explicit via
94
+ # the /carts/:id/complete endpoint otherwise gift cards or store
95
+ # credits that fully cover the order total would auto-complete the
96
+ # cart during address/delivery updates.
98
97
  def try_advance
99
98
  return if cart.complete? || cart.canceled?
100
99
 
100
+ steps = cart.checkout_steps
101
101
  loop do
102
+ current_index = steps.index(cart.state).to_i
103
+ next_step = steps[current_index + 1]
104
+ break if next_step.nil? || next_step == 'complete'
102
105
  break unless cart.next
103
- break if cart.confirm? || cart.complete?
104
106
  end
105
107
  rescue StandardError => e
106
108
  Rails.error.report(e, context: { order_id: cart.id, state: cart.state }, source: 'spree.checkout')
@@ -12,9 +12,13 @@ module Spree
12
12
  return failure(nil, Spree.t(:error_user_does_not_have_any_store_credits)) unless @order.user&.store_credits&.any?
13
13
 
14
14
  ApplicationRecord.transaction do
15
- @order.payments.store_credits.where(state: :checkout).map(&:invalidate!)
15
+ existing = @order.payments.store_credits.where(state: :checkout)
16
16
 
17
- apply_store_credits(remaining_total)
17
+ if existing.any?
18
+ update_existing_payments(existing, remaining_total)
19
+ else
20
+ apply_store_credits(remaining_total)
21
+ end
18
22
  end
19
23
 
20
24
  if @order.reload.payments.store_credits.valid.any?
@@ -27,6 +31,26 @@ module Spree
27
31
 
28
32
  private
29
33
 
34
+ # Update existing checkout store credit payments in place to avoid
35
+ # creating unnecessary invalid payment records on every recalculation.
36
+ def update_existing_payments(payments, remaining_total)
37
+ payments.each do |payment|
38
+ credit = payment.source
39
+ available = credit.amount_remaining + payment.amount
40
+ new_amount = [available, remaining_total].min
41
+
42
+ if new_amount.positive?
43
+ payment.update_column(:amount, new_amount)
44
+ remaining_total -= new_amount
45
+ else
46
+ payment.invalidate!
47
+ end
48
+ end
49
+
50
+ # If there's still remaining total, apply from additional store credits
51
+ apply_store_credits(remaining_total) if remaining_total.positive?
52
+ end
53
+
30
54
  def apply_store_credits(remaining_total)
31
55
  payment_method = Spree::PaymentMethod::StoreCredit.available.first
32
56
  raise 'Store credit payment method could not be found' unless payment_method
@@ -1222,6 +1222,7 @@ en:
1222
1222
  gift_card_expired: The Gift Card has expired.
1223
1223
  gift_card_mismatched_currency: Gift Card cannot be applied with current currency.
1224
1224
  gift_card_mismatched_customer: This gift card is associated with another user.
1225
+ gift_card_not_found: The Gift Card was not found.
1225
1226
  gift_card_removed: The Gift Card was successfully removed from your order
1226
1227
  gift_card_using_store_credit_error: You can't apply the Gift Card after you applied the store credit.
1227
1228
  gift_cards: Gift Cards
@@ -0,0 +1,20 @@
1
+ class CreateSpreePriceHistories < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :spree_price_histories do |t|
4
+ t.references :price, null: false, index: false
5
+ t.references :variant, null: false, index: false
6
+ t.decimal :amount, precision: 10, scale: 2, null: false
7
+ t.decimal :compare_at_amount, precision: 10, scale: 2
8
+ t.string :currency, null: false
9
+ t.datetime :recorded_at, null: false
10
+ t.datetime :created_at, null: false
11
+ end
12
+
13
+ add_index :spree_price_histories, [:variant_id, :currency, :recorded_at],
14
+ name: 'idx_price_histories_variant_currency_recorded'
15
+ add_index :spree_price_histories, [:price_id, :recorded_at],
16
+ name: 'idx_price_histories_price_recorded'
17
+ add_index :spree_price_histories, :recorded_at,
18
+ name: 'idx_price_histories_recorded_at'
19
+ end
20
+ end
@@ -96,6 +96,8 @@ module Spree
96
96
  preference :tax_using_ship_address, :boolean, default: true
97
97
  preference :title_site_name_separator, :string, deprecated: true
98
98
  preference :track_inventory_levels, :boolean, default: true # Determines whether to track on_hand values for variants / products.
99
+ preference :track_price_history, :boolean, default: true # Records price changes for Omnibus Directive compliance. Disable for non-EU stores.
100
+ preference :price_history_retention_days, :integer, default: 30 # Days to retain price history records. Used by spree:price_history:prune rake task.
99
101
  preference :use_user_locale, :boolean, default: true
100
102
 
101
103
  # Sets the path used for products, taxons and pages.
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.0.rc2'.freeze
2
+ VERSION = '5.4.0.rc3'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -0,0 +1,9 @@
1
+ FactoryBot.define do
2
+ factory :price_history, class: Spree::PriceHistory do
3
+ price
4
+ variant { price.variant }
5
+ amount { price.amount }
6
+ currency { price.currency }
7
+ recorded_at { Time.current }
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :spree do
4
+ namespace :price_history do
5
+ desc 'Seed price history from existing base prices (run once after migration)'
6
+ task seed: :environment do
7
+ count = 0
8
+ Spree::Price.where(deleted_at: nil, price_list_id: nil).find_each do |price|
9
+ next if price.amount.nil?
10
+ next if Spree::PriceHistory.exists?(price_id: price.id)
11
+
12
+ Spree::PriceHistory.create!(
13
+ price: price,
14
+ variant_id: price.variant_id,
15
+ amount: price.amount,
16
+ compare_at_amount: price.compare_at_amount,
17
+ currency: price.currency,
18
+ recorded_at: price.updated_at || Time.current
19
+ )
20
+ count += 1
21
+ end
22
+
23
+ puts "Seeded #{count} price history records"
24
+ end
25
+
26
+ desc 'Prune price history older than retention period'
27
+ task prune: :environment do
28
+ retention_days = Spree::Config[:price_history_retention_days] || 30
29
+ deleted = Spree::PriceHistory
30
+ .where('recorded_at < ?', retention_days.days.ago)
31
+ .delete_all
32
+
33
+ puts "Pruned #{deleted} price history records older than #{retention_days} days"
34
+ end
35
+ end
36
+ end
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: 5.4.0.rc2
4
+ version: 5.4.0.rc3
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-03-23 00:00:00.000000000 Z
13
+ date: 2026-03-25 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -1052,6 +1052,7 @@ files:
1052
1052
  - app/models/spree/policy.rb
1053
1053
  - app/models/spree/preference.rb
1054
1054
  - app/models/spree/price.rb
1055
+ - app/models/spree/price_history.rb
1055
1056
  - app/models/spree/price_list.rb
1056
1057
  - app/models/spree/price_rule.rb
1057
1058
  - app/models/spree/price_rules/customer_group_rule.rb
@@ -1472,6 +1473,7 @@ files:
1472
1473
  - db/migrate/20260315000000_create_spree_allowed_origins.rb
1473
1474
  - db/migrate/20260315100000_add_product_media_support.rb
1474
1475
  - db/migrate/20260317000000_create_spree_refresh_tokens.rb
1476
+ - db/migrate/20260323000000_create_spree_price_histories.rb
1475
1477
  - db/sample_data/customers.csv
1476
1478
  - db/sample_data/metafield_definitions.rb
1477
1479
  - db/sample_data/orders.rb
@@ -1599,6 +1601,7 @@ files:
1599
1601
  - lib/spree/testing_support/factories/payment_source_factory.rb
1600
1602
  - lib/spree/testing_support/factories/policy_factory.rb
1601
1603
  - lib/spree/testing_support/factories/price_factory.rb
1604
+ - lib/spree/testing_support/factories/price_history_factory.rb
1602
1605
  - lib/spree/testing_support/factories/price_list_factory.rb
1603
1606
  - lib/spree/testing_support/factories/price_rule_factory.rb
1604
1607
  - lib/spree/testing_support/factories/product_factory.rb
@@ -1670,6 +1673,7 @@ files:
1670
1673
  - lib/tasks/dependencies.rake
1671
1674
  - lib/tasks/images.rake
1672
1675
  - lib/tasks/markets.rake
1676
+ - lib/tasks/price_history.rake
1673
1677
  - lib/tasks/products.rake
1674
1678
  - lib/tasks/sample_data.rake
1675
1679
  - lib/tasks/search.rake
@@ -1695,9 +1699,9 @@ licenses:
1695
1699
  - BSD-3-Clause
1696
1700
  metadata:
1697
1701
  bug_tracker_uri: https://github.com/spree/spree/issues
1698
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc2
1702
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc3
1699
1703
  documentation_uri: https://docs.spreecommerce.org/
1700
- source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc2
1704
+ source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc3
1701
1705
  post_install_message:
1702
1706
  rdoc_options: []
1703
1707
  require_paths: