Skip to content

Commit 96c0357

Browse files
authored
Merge pull request #5872 from SuperGoodSoft/in-memory-order-updater
In-memory order updater
2 parents 997f46a + 975cb36 commit 96c0357

24 files changed

Lines changed: 1626 additions & 372 deletions

Gemfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ gem 'puma', '< 7', require: false
3535
gem 'i18n-tasks', '~> 1.1.0', require: false
3636
gem 'rspec_junit_formatter', require: false
3737
gem 'yard', require: false
38-
gem 'db-query-matchers', require: false
3938

4039
if ENV['GITHUB_ACTIONS']
4140
gem "rspec-github", "~> 3.0", require: false

admin/spec/spec_helper.rb

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,6 @@
7878
require 'axe-rspec'
7979
require 'axe-capybara'
8080

81-
# DB Query Matchers
82-
require "db-query-matchers"
83-
DBQueryMatchers.configure do |config|
84-
config.ignores = [/SHOW TABLES LIKE/]
85-
config.ignore_cached = true
86-
config.schemaless = true
87-
end
88-
8981
RSpec.configure do |config|
9082
if ENV["GITHUB_ACTIONS"]
9183
require "rspec/github"
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# frozen_string_literal: true
2+
3+
require 'spree/manipulative_query_monitor'
4+
5+
module Spree
6+
class InMemoryOrderUpdater
7+
attr_reader :order
8+
9+
# logs a warning when a manipulative query is made when the persist flag is set to false
10+
class_attribute :log_manipulative_queries
11+
self.log_manipulative_queries = true
12+
13+
delegate :payments, :line_items, :adjustments, :all_adjustments, :shipments, :quantity, to: :order
14+
15+
def initialize(order)
16+
@order = order
17+
end
18+
19+
# This is a multi-purpose method for processing logic related to changes in the Order.
20+
# It is meant to be called from various observers so that the Order is aware of changes
21+
# that affect totals and other values stored in the Order.
22+
#
23+
# This method should never do anything to the Order that results in a save call on the
24+
# object with callbacks (otherwise you will end up in an infinite recursion as the
25+
# associations try to save and then in turn try to call +update!+ again.)
26+
def recalculate(persist: true)
27+
monitor =
28+
if log_manipulative_queries
29+
Spree::ManipulativeQueryMonitor
30+
else
31+
proc { |&block| block.call }
32+
end
33+
34+
order.transaction do
35+
monitor.call do
36+
recalculate_line_item_prices
37+
recalculate_item_count
38+
assign_shipment_amounts
39+
end
40+
41+
if persist
42+
update_totals(persist:)
43+
else
44+
monitor.call do
45+
update_totals(persist:)
46+
end
47+
end
48+
49+
monitor.call do
50+
if order.completed?
51+
recalculate_payment_state
52+
recalculate_shipment_state
53+
end
54+
end
55+
56+
Spree::Bus.publish(:order_recalculated, order:)
57+
58+
persist_totals if persist
59+
end
60+
end
61+
alias_method :update, :recalculate
62+
deprecate update: :recalculate, deprecator: Spree.deprecator
63+
64+
# Recalculates the state on all of them shipments, then recalculates the
65+
# +shipment_state+ attribute according to the following logic:
66+
#
67+
# shipped when all Shipments are in the "shipped" state
68+
# partial when at least one Shipment has a state of "shipped" and there is another Shipment with a state other than "shipped"
69+
# or there are InventoryUnits associated with the order that have a state of "sold" but are not associated with a Shipment.
70+
# ready when all Shipments are in the "ready" state
71+
# backorder when there is backordered inventory associated with an order
72+
# pending when all Shipments are in the "pending" state
73+
#
74+
# The +shipment_state+ value helps with reporting, etc. since it provides a quick and easy way to locate Orders needing attention.
75+
def recalculate_shipment_state
76+
shipments.each(&:recalculate_state)
77+
78+
order.shipment_state = determine_shipment_state
79+
order.shipment_state
80+
end
81+
alias_method :update_shipment_state, :recalculate_shipment_state
82+
deprecate update_shipment_state: :recalculate_shipment_state, deprecator: Spree.deprecator
83+
84+
# Recalculates the +payment_state+ attribute according to the following logic:
85+
#
86+
# paid when +payment_total+ is equal to +total+
87+
# balance_due when +payment_total+ is less than +total+
88+
# credit_owed when +payment_total+ is greater than +total+
89+
# failed when most recent payment is in the failed state
90+
# void when the order has been canceled and the payment total is 0
91+
#
92+
# The +payment_state+ value helps with reporting, etc. since it provides a quick and easy way to locate Orders needing attention.
93+
def recalculate_payment_state
94+
order.payment_state = determine_payment_state
95+
order.payment_state
96+
end
97+
alias_method :update_payment_state, :recalculate_payment_state
98+
deprecate update_payment_state: :recalculate_payment_state, deprecator: Spree.deprecator
99+
100+
private
101+
102+
def determine_payment_state
103+
if payments.present? && payments.valid.empty? && order.outstanding_balance != 0
104+
'failed'
105+
elsif order.state == 'canceled' && order.payment_total.zero?
106+
'void'
107+
elsif order.outstanding_balance > 0
108+
'balance_due'
109+
elsif order.outstanding_balance < 0
110+
'credit_owed'
111+
else
112+
# outstanding_balance == 0
113+
'paid'
114+
end
115+
end
116+
117+
def determine_shipment_state
118+
if order.backordered?
119+
'backorder'
120+
else
121+
# get all the shipment states for this order
122+
shipment_states = shipments.states
123+
if shipment_states.size > 1
124+
# multiple shiment states means it's most likely partially shipped
125+
'partial'
126+
else
127+
# will return nil if no shipments are found
128+
shipment_states.first
129+
end
130+
end
131+
end
132+
133+
# This will update and select the best promotion adjustment, update tax
134+
# adjustments, update cancellation adjustments, and then update the total
135+
# fields (promo_total, included_tax_total, additional_tax_total, and
136+
# adjustment_total) on the item.
137+
# @return [void]
138+
def update_adjustments(persist:)
139+
# Promotion adjustments must be applied first, then tax adjustments.
140+
# This fits the criteria for VAT tax as outlined here:
141+
# http://www.hmrc.gov.uk/vat/managing/charging/discounts-etc.htm#1
142+
# It also fits the criteria for sales tax as outlined here:
143+
# http://www.boe.ca.gov/formspubs/pub113/
144+
update_promotions(persist:)
145+
update_tax_adjustments
146+
assign_item_totals
147+
end
148+
149+
# Updates the following Order total values:
150+
#
151+
# +payment_total+ The total value of all finalized Payments (NOTE: non-finalized Payments are excluded)
152+
# +item_total+ The total value of all LineItems
153+
# +adjustment_total+ The total value of all adjustments (promotions, credits, etc.)
154+
# +promo_total+ The total value of all promotion adjustments
155+
# +total+ The so-called "order total." This is equivalent to +item_total+ plus +adjustment_total+.
156+
def update_totals(persist:)
157+
recalculate_payment_total
158+
recalculate_item_total
159+
recalculate_shipment_total
160+
update_adjustment_total(persist:)
161+
end
162+
163+
def assign_shipment_amounts
164+
shipments.each(&:assign_amounts)
165+
end
166+
167+
def update_adjustment_total(persist:)
168+
update_adjustments(persist:)
169+
170+
all_items = line_items + shipments
171+
# Ignore any adjustments that have been marked for destruction in our
172+
# calculations. They'll get removed when/if we persist the order.
173+
valid_adjustments = adjustments.reject(&:marked_for_destruction?)
174+
order_tax_adjustments = valid_adjustments.select(&:tax?)
175+
176+
order.adjustment_total = all_items.sum(&:adjustment_total) + valid_adjustments.sum(&:amount)
177+
order.included_tax_total = all_items.sum(&:included_tax_total) + order_tax_adjustments.select(&:included?).sum(&:amount)
178+
order.additional_tax_total = all_items.sum(&:additional_tax_total) + order_tax_adjustments.reject(&:included?).sum(&:amount)
179+
180+
recalculate_order_total
181+
end
182+
183+
def update_promotions(persist:)
184+
Spree::Config.promotions.order_adjuster_class.new(order).call(persist:)
185+
end
186+
187+
def update_tax_adjustments
188+
Spree::Config.tax_adjuster_class.new(order).adjust!
189+
end
190+
191+
def update_cancellations
192+
end
193+
deprecate :update_cancellations, deprecator: Spree.deprecator
194+
195+
def assign_item_totals
196+
[*line_items, *shipments].each do |item|
197+
Spree::Config.item_total_class.new(item).recalculate!
198+
199+
next unless item.changed?
200+
201+
item.assign_attributes(
202+
promo_total: item.promo_total,
203+
included_tax_total: item.included_tax_total,
204+
additional_tax_total: item.additional_tax_total,
205+
adjustment_total: item.adjustment_total
206+
)
207+
end
208+
end
209+
210+
def recalculate_payment_total
211+
order.payment_total = payments.completed.includes(:refunds).sum { |payment| payment.amount - payment.refunds.sum(:amount) }
212+
end
213+
214+
def recalculate_shipment_total
215+
order.shipment_total = shipments.to_a.sum(&:cost)
216+
recalculate_order_total
217+
end
218+
219+
def recalculate_order_total
220+
order.total = order.item_total + order.shipment_total + order.adjustment_total
221+
end
222+
223+
def recalculate_item_count
224+
order.item_count = line_items.to_a.sum(&:quantity)
225+
end
226+
227+
def recalculate_item_total
228+
order.item_total = line_items.to_a.sum(&:amount)
229+
recalculate_order_total
230+
end
231+
232+
def recalculate_line_item_prices
233+
if Spree::Config.recalculate_cart_prices
234+
line_items.each(&:recalculate_price)
235+
end
236+
end
237+
238+
def persist_totals
239+
shipments.each(&:persist_amounts)
240+
assign_item_totals
241+
log_state_change("payment")
242+
log_state_change("shipment")
243+
order.save!
244+
end
245+
246+
def log_state_change(name)
247+
state = "#{name}_state"
248+
previous_state, current_state = order.changes[state]
249+
250+
if previous_state != current_state
251+
# Enqueue the job to track this state change
252+
StateChangeTrackingJob.perform_later(
253+
order,
254+
previous_state,
255+
current_state,
256+
Time.current,
257+
name
258+
)
259+
end
260+
end
261+
end
262+
end

core/app/models/spree/null_promotion_adjuster.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ def initialize(order)
66
@order = order
77
end
88

9-
def call
9+
def call(persist: true) # rubocop:disable Lint/UnusedMethodArgument
1010
@order
1111
end
1212
end

core/app/models/spree/order.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,7 @@ def ensure_inventory_units
804804
end
805805

806806
def ensure_promotions_eligible
807-
Spree::Config.promotions.order_adjuster_class.new(self).call
807+
Spree::Config.promotions.order_adjuster_class.new(self).call(persist: false)
808808

809809
if promo_total_changed?
810810
restart_checkout_flow

core/app/models/spree/shipment.rb

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,14 +278,21 @@ def tracking_url
278278
end
279279

280280
def update_amounts
281-
if selected_shipping_rate
282-
self.cost = selected_shipping_rate.cost
283-
if changed?
284-
update_columns(
285-
cost:,
286-
updated_at: Time.current
287-
)
288-
end
281+
assign_amounts
282+
persist_amounts
283+
end
284+
285+
def assign_amounts
286+
return unless selected_shipping_rate
287+
self.cost = selected_shipping_rate.cost
288+
end
289+
290+
def persist_amounts
291+
if cost_changed?
292+
update_columns(
293+
cost:,
294+
updated_at: Time.current
295+
)
289296
end
290297
end
291298

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
require "db-query-matchers"
4+
5+
DBQueryMatchers.configure do |config|
6+
config.ignores = [/SHOW TABLES LIKE/]
7+
config.ignore_cached = true
8+
config.schemaless = true
9+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
module Spree
4+
class ManipulativeQueryMonitor
5+
def self.call(&block)
6+
counter = ::DBQueryMatchers::QueryCounter.new({ matches: [/^\ *(INSERT|UPDATE|DELETE\ FROM)/] })
7+
ActiveSupport::Notifications.subscribed(counter.to_proc,
8+
"sql.active_record",
9+
&block)
10+
if counter.count > 0
11+
message = "Detected #{counter.count} manipulative queries. #{counter.log.join(', ')}\n"
12+
13+
message += caller.select{ |line| line.include?(Rails.root.to_s) || line.include?('solidus') }.join("\n")
14+
15+
Rails.logger.warn(message)
16+
end
17+
end
18+
end
19+
end

core/solidus_core.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Gem::Specification.new do |s|
3838
s.add_dependency 'awesome_nested_set', ['~> 3.3', '>= 3.7.0']
3939
s.add_dependency 'cancancan', ['>= 2.2', '< 4.0']
4040
s.add_dependency 'carmen', '~> 1.1.0'
41+
s.add_dependency 'db-query-matchers', '~> 0.14'
4142
s.add_dependency 'discard', '~> 1.0'
4243
s.add_dependency 'friendly_id', '~> 5.0'
4344
s.add_dependency 'image_processing', '~> 1.10'

0 commit comments

Comments
 (0)