Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

In-memory order updater #5872

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c261747
Refactor line item total calculations
jarednorman Jan 17, 2025
7a3bfc2
Make ItemTotal class configurable
adammathys Jan 31, 2025
1e9938d
Set tax adjustment amount without persistance
harmonymjb Jan 3, 2025
04ac938
Mark adjustments for removal in OrderTaxation
Noah-Silvera Jan 10, 2025
6019f2e
Ignore order adjustments marked for destruction
adammathys Jan 31, 2025
cf2a5d7
Add `db-query-matchers` gem
benjaminwil Oct 11, 2024
ba16615
Copy existing `OrderUpdater` implementation
benjaminwil Oct 11, 2024
df8ee4f
Add `persist` flag to `#recalculate`
benjaminwil Oct 11, 2024
c81d1f8
Add describe block to Shipment#update_amounts test
Noah-Silvera Oct 25, 2024
e74e951
Conditionally persist Shipment#update_amounts changes
Noah-Silvera Oct 25, 2024
3cb496b
Preventing InMemoryOrderUpdater#update_shipment_amounts from making d…
Noah-Silvera Oct 25, 2024
64aa400
Rename method that recalculates shipment state
forkata Nov 8, 2024
a2d57a8
Rename method that recalculates payment state
forkata Nov 8, 2024
cafb9fa
Rename update_ private methods
AlistairNorman Nov 22, 2024
e96497a
Rename recalculate_adjustments
AlistairNorman Nov 22, 2024
5042867
Remove describe block for private method
AlistairNorman Nov 22, 2024
a9e952e
Reorder private methods
AlistairNorman Nov 22, 2024
eed8e61
Test that changes to item totals are respected
adammathys Jan 30, 2025
a6b1d4d
Pass persist flag to legacy promotion recalculator
adammathys Jan 31, 2025
2c886b3
Pass persist to promotion.order_adjuster_class
sofiabesenski4 Dec 6, 2024
4279c1d
Support conditional persist in promotion chooser
AlistairNorman Feb 7, 2025
e7faef3
[TODO] Add TODO so we know what to do
forkata Nov 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ gem 'pg', '~> 1.0', require: false if dbs.match?(/all|postgres/)
gem 'fast_sqlite', require: false if dbs.match?(/all|sqlite/)
gem 'sqlite3', '>= 2.1', require: false if dbs.match?(/all|sqlite/)


gem 'db-query-matchers'
gem 'database_cleaner', '~> 2.0', require: false
gem 'rspec-activemodel-mocks', '~> 1.1', require: false
gem 'rspec-rails', '~> 6.0.3', require: false
Expand Down
23 changes: 23 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
In-Memory Order Updater TODO
===

- [x] Add additional cases to item_total_updater_spec (doesn't currently account for included adjustments)
- [x] Consider Sofia's recommendation to break this class into POROs to simplify testing
- [x] Add test coverage for `recalculate_item_total` when line item totals change
- [x] Scope handling of tax adjustments in `InMemoryOrderUpdater` to *not* marked for destruction
- [x] Scope handling of tax adjustments in `OrderUpdater` to *not* marked for destruction
- [x] Ensure order-level tax adjustments (like Colorado delivery) are scoped out of tax total and adjustment total calculations
- [x] Handle persistence in `update_taxes`
- [x] ~Write the `InMemoryOrderAdjuster` (also, should we rename this to `InMemoryOrderPromotionAdjuster`)~
- [In Progress] Handle persistence in all implementations of `promotions.order_adjuster_class`
- Follow up on any failing test relating to change in promotion chooser
- Continue on with new promotion system similar change
- [ ] Investigate if any promotion actions write to the database when calling `compute_amount`
- Create quantity adjustments, this action persists when compute_amount is called
- [ ] Test coverage to ensure state changes aren't persisted (if someone changes current implementation)
- [ ] We should be able to blow up if something tries to persist
- https://github.com/sds/db-query-matchers/blob/0deaaac360f43e6cc15c03a7fca8425cf65dd703/lib/db_query_matchers/make_database_queries.rb#L74-L82
- https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html#method-c-subscribed
- "By calling this in memory order updater, we are making a contract with the user that it will be in memory"
- "This is really something which theoretically should be covered in tests"

235 changes: 235 additions & 0 deletions core/app/models/spree/in_memory_order_updater.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
# frozen_string_literal: true

module Spree
class InMemoryOrderUpdater
attr_reader :order

delegate :payments, :line_items, :adjustments, :all_adjustments, :shipments, :quantity, to: :order

def initialize(order)
@order = order
end

# This is a multi-purpose method for processing logic related to changes in the Order.
# It is meant to be called from various observers so that the Order is aware of changes
# that affect totals and other values stored in the Order.
#
# This method should never do anything to the Order that results in a save call on the
# object with callbacks (otherwise you will end up in an infinite recursion as the
# associations try to save and then in turn try to call +update!+ again.)
def recalculate(persist: true)
order.transaction do
recalculate_item_count
update_shipment_amounts(persist:)
update_totals(persist:)
if order.completed?
recalculate_payment_state
update_shipments
recalculate_shipment_state
end

Spree::Bus.publish(:order_recalculated, order:)

persist_totals if persist
end
end
alias_method :update, :recalculate
deprecate update: :recalculate, deprecator: Spree.deprecator

# Recalculates the +shipment_state+ attribute according to the following logic:
#
# shipped when all Shipments are in the "shipped" state
# partial when at least one Shipment has a state of "shipped" and there is another Shipment with a state other than "shipped"
# or there are InventoryUnits associated with the order that have a state of "sold" but are not associated with a Shipment.
# ready when all Shipments are in the "ready" state
# backorder when there is backordered inventory associated with an order
# pending when all Shipments are in the "pending" state
#
# The +shipment_state+ value helps with reporting, etc. since it provides a quick and easy way to locate Orders needing attention.
def recalculate_shipment_state
log_state_change('shipment') do
order.shipment_state = determine_shipment_state
end

order.shipment_state
end
alias_method :update_shipment_state, :recalculate_shipment_state
deprecate update_shipment_state: :recalculate_shipment_state, deprecator: Spree.deprecator

# Recalculates the +payment_state+ attribute according to the following logic:
#
# paid when +payment_total+ is equal to +total+
# balance_due when +payment_total+ is less than +total+
# credit_owed when +payment_total+ is greater than +total+
# failed when most recent payment is in the failed state
# void when the order has been canceled and the payment total is 0
#
# The +payment_state+ value helps with reporting, etc. since it provides a quick and easy way to locate Orders needing attention.
def recalculate_payment_state
log_state_change('payment') do
order.payment_state = determine_payment_state
end

order.payment_state
end
alias_method :update_payment_state, :recalculate_shipment_state
deprecate update_payment_state: :recalculate_shipment_state, deprecator: Spree.deprecator

private

def determine_payment_state
if payments.present? && payments.valid.empty? && order.outstanding_balance != 0
'failed'
elsif order.state == 'canceled' && order.payment_total.zero?
'void'
elsif order.outstanding_balance > 0
'balance_due'
elsif order.outstanding_balance < 0
'credit_owed'
else
# outstanding_balance == 0
'paid'
end
end

def determine_shipment_state
if order.backordered?
'backorder'
else
# get all the shipment states for this order
shipment_states = shipments.states
if shipment_states.size > 1
# multiple shiment states means it's most likely partially shipped
'partial'
else
# will return nil if no shipments are found
shipment_states.first
end
end
end

# This will update and select the best promotion adjustment, update tax
# adjustments, update cancellation adjustments, and then update the total
# fields (promo_total, included_tax_total, additional_tax_total, and
# adjustment_total) on the item.
# @return [void]
def update_adjustments(persist:)
# Promotion adjustments must be applied first, then tax adjustments.
# This fits the criteria for VAT tax as outlined here:
# http://www.hmrc.gov.uk/vat/managing/charging/discounts-etc.htm#1
# It also fits the criteria for sales tax as outlined here:
# http://www.boe.ca.gov/formspubs/pub113/
update_promotions(persist:)
update_tax_adjustments
update_item_totals(persist:)
end

# Updates the following Order total values:
#
# +payment_total+ The total value of all finalized Payments (NOTE: non-finalized Payments are excluded)
# +item_total+ The total value of all LineItems
# +adjustment_total+ The total value of all adjustments (promotions, credits, etc.)
# +promo_total+ The total value of all promotion adjustments
# +total+ The so-called "order total." This is equivalent to +item_total+ plus +adjustment_total+.
def update_totals(persist:)
recalculate_payment_total
recalculate_item_total
recalculate_shipment_total
update_adjustment_total(persist:)
end

def update_shipment_amounts(persist:)
shipments.each { _1.update_amounts(persist:) }
end

def update_adjustment_total(persist:)
update_adjustments(persist:)

all_items = line_items + shipments
# Ignore any adjustments that have been marked for destruction in our
# calculations. They'll get removed when/if we persist the order.
valid_adjustments = adjustments.reject(&:marked_for_destruction?)
order_tax_adjustments = valid_adjustments.select(&:tax?)

order.adjustment_total = all_items.sum(&:adjustment_total) + valid_adjustments.sum(&:amount)
order.included_tax_total = all_items.sum(&:included_tax_total) + order_tax_adjustments.select(&:included?).sum(&:amount)
order.additional_tax_total = all_items.sum(&:additional_tax_total) + order_tax_adjustments.reject(&:included?).sum(&:amount)

recalculate_order_total
end

def update_promotions(persist:)
Spree::Config.promotions.order_adjuster_class.new(order).call(persist:)
end

def update_tax_adjustments
Spree::Config.tax_adjuster_class.new(order).adjust!
end

def update_cancellations
end
deprecate :update_cancellations, deprecator: Spree.deprecator

def update_item_totals(persist:)
[*line_items, *shipments].each do |item|
Spree::Config.item_total_class.new(item).recalculate!

next unless persist && item.changed?

item.update_columns(
promo_total: item.promo_total,
included_tax_total: item.included_tax_total,
additional_tax_total: item.additional_tax_total,
adjustment_total: item.adjustment_total,
updated_at: Time.current,
)
end
end

# give each of the shipments a chance to update themselves
def update_shipments
shipments.each(&:update_state)
end

def recalculate_payment_total
order.payment_total = payments.completed.includes(:refunds).sum { |payment| payment.amount - payment.refunds.sum(:amount) }
end

def recalculate_shipment_total
order.shipment_total = shipments.to_a.sum(&:cost)
recalculate_order_total
end

def recalculate_order_total
order.total = order.item_total + order.shipment_total + order.adjustment_total
end

def recalculate_item_count
order.item_count = line_items.to_a.sum(&:quantity)
end

def recalculate_item_total
order.item_total = line_items.to_a.sum(&:amount)
recalculate_order_total
end

def persist_totals
order.save!
end

def log_state_change(name)
state = "#{name}_state"
old_state = order.public_send(state)
yield
new_state = order.public_send(state)
if old_state != new_state
order.state_changes.new(
previous_state: old_state,
next_state: new_state,
user_id: order.user_id,
name:
)
end
end
end
end
28 changes: 28 additions & 0 deletions core/app/models/spree/item_total.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

class Spree::ItemTotal
def initialize(item)
@item = item
end

def recalculate!
tax_adjustments = item.adjustments.select do |adjustment|
adjustment.tax? && !adjustment.marked_for_destruction?
end

# Included tax adjustments are those which are included in the price.
# These ones should not affect the eventual total price.
#
# Additional tax adjustments are the opposite, affecting the final total.
item.included_tax_total = tax_adjustments.select(&:included?).sum(&:amount)
item.additional_tax_total = tax_adjustments.reject(&:included?).sum(&:amount)

item.adjustment_total = item.adjustments.reject { |adjustment|
adjustment.marked_for_destruction? || adjustment.included?
}.sum(&:amount)
end

private

attr_reader :item
end
2 changes: 1 addition & 1 deletion core/app/models/spree/line_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class LineItem < Spree::Base

has_one :product, through: :variant

has_many :adjustments, as: :adjustable, inverse_of: :adjustable, dependent: :destroy
has_many :adjustments, as: :adjustable, inverse_of: :adjustable, dependent: :destroy, autosave: true
has_many :inventory_units, inverse_of: :line_item

before_validation :normalize_quantity
Expand Down
2 changes: 1 addition & 1 deletion core/app/models/spree/null_promotion_adjuster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ def initialize(order)
@order = order
end

def call
def call(persist: true)
@order
end
end
Expand Down
2 changes: 1 addition & 1 deletion core/app/models/spree/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def states
has_many :cartons, -> { distinct }, through: :inventory_units

# Adjustments and promotions
has_many :adjustments, -> { order(:created_at) }, as: :adjustable, inverse_of: :adjustable, dependent: :destroy
has_many :adjustments, -> { order(:created_at) }, as: :adjustable, inverse_of: :adjustable, dependent: :destroy, autosave: true
has_many :line_item_adjustments, through: :line_items, source: :adjustments
has_many :shipment_adjustments, through: :shipments, source: :adjustments
has_many :all_adjustments,
Expand Down
5 changes: 3 additions & 2 deletions core/app/models/spree/order_taxation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def update_adjustments(item, taxed_items)

# Remove any tax adjustments tied to rates which no longer match.
unmatched_adjustments = tax_adjustments - active_adjustments
item.adjustments.destroy(unmatched_adjustments)
unmatched_adjustments.each(&:mark_for_destruction)
end

# Update or create a new tax adjustment on an item.
Expand All @@ -77,7 +77,8 @@ def update_adjustment(item, tax_item)
label: tax_item.label,
included: tax_item.included_in_price
)
tax_adjustment.update!(amount: tax_item.amount)

tax_adjustment.amount = tax_item.amount
tax_adjustment
end
end
Expand Down
Loading
Loading