-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Add a PORO for creating tax adjustments #685
Changes from all commits
89a0168
f7edf46
b24c9cb
49b8aa2
132f02f
7a1117e
d5e15d0
11a9b3d
52ca997
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
module Spree | ||
module Tax | ||
# Adjust a single taxable item (line item or shipment) | ||
class ItemAdjuster | ||
attr_reader :item, :order | ||
|
||
include TaxHelpers | ||
|
||
# @param [Spree::LineItem,Spree::Shipment] item to adjust | ||
# @param [Hash] options like already known tax rates for the order's zone | ||
def initialize(item, options = {}) | ||
@item = item | ||
@order = @item.order | ||
# set instance variable so `TaxRate.match` is only called when necessary | ||
@rates_for_order_zone = options[:rates_for_order_zone] | ||
end | ||
|
||
# Deletes all existing tax adjustments and creates new adjustments for all | ||
# (geographically and category-wise) applicable tax rates. | ||
# | ||
# Creating the adjustments will also run the ItemAdjustments class and | ||
# persist all taxation and promotion totals on the item. | ||
# | ||
# @return [Array<Spree::Adjustment>] newly created adjustments | ||
def adjust! | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we add some YARD here to explain the return values of this method. I also tend to to not have a method ending in a bang without a corresponding non-bang method. See http://dablog.rubypal.com/2007/8/15/bang-methods-or-danger-will-rubyist There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can very well imagine a future non-bang YARD I can absolutely add, as this method returns the newly created adjustments (making it somewhat easier to test than |
||
return unless order_tax_zone | ||
# Using .destroy_all to make sure callbacks fire | ||
item.adjustments.tax.destroy_all | ||
|
||
TaxRate.store_pre_tax_amount(item, rates_for_item) | ||
|
||
rates_for_item.map { |rate| rate.adjust(order_tax_zone, item) } | ||
end | ||
|
||
private | ||
|
||
def rates_for_item | ||
@rates_for_item ||= applicable_rates.select { |rate| rate.tax_category == item.tax_category } | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
module Spree | ||
module Tax | ||
# Add tax adjustments to all line items and shipments in an order | ||
class OrderAdjuster | ||
attr_reader :order | ||
|
||
include TaxHelpers | ||
|
||
# @param [Spree::Order] order to be adjusted | ||
def initialize(order) | ||
@order = order | ||
end | ||
|
||
# Creates tax adjustments for all taxable items (shipments and line items) | ||
# in the given order. | ||
def adjust! | ||
return unless order_tax_zone | ||
|
||
(order.line_items + order.shipments).each do |item| | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a concept we should have on the order model? Some sort of method that returns all things that can be adjusted? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could, potentially. I shied away of adding more methods to |
||
ItemAdjuster.new(item, rates_for_order_zone: applicable_rates).adjust! | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
module Spree | ||
module Tax | ||
module TaxHelpers | ||
private | ||
|
||
# Imagine with me this scenario: | ||
# You are living in Spain and you have a store which ships to France. | ||
# Spain is therefore your default tax rate. | ||
# When you ship to Spain, you want the Spanish rate to apply. | ||
# When you ship to France, you want the French rate to apply. | ||
# | ||
# Normally, Spree would notice that you have two potentially applicable | ||
# tax rates for one particular item. | ||
# When you ship to Spain, only the Spanish one will apply. | ||
# When you ship to France, you'll see a Spanish refund AND a French tax. | ||
# This little bit of code at the end stops the Spanish refund from appearing. | ||
# | ||
# For further discussion, see https://github.com/spree/spree/issues/4397 and https://github.com/spree/spree/issues/4327. | ||
def applicable_rates | ||
order_zone_tax_categories = rates_for_order_zone.map(&:tax_category) | ||
default_rates_with_unmatched_tax_category = rates_for_default_zone.to_a.delete_if do |default_rate| | ||
order_zone_tax_categories.include?(default_rate.tax_category) | ||
end | ||
|
||
(rates_for_order_zone + default_rates_with_unmatched_tax_category).uniq | ||
end | ||
|
||
def rates_for_order_zone | ||
@rates_for_order_zone ||= Spree::TaxRate.for_zone(order_tax_zone) | ||
end | ||
|
||
def rates_for_default_zone | ||
@rates_for_default_zone ||= Spree::TaxRate.for_zone(Spree::Zone.default_tax) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here the same rule for naming caching instance variables applies |
||
end | ||
|
||
def order_tax_zone | ||
@order_tax_zone ||= order.tax_zone | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -58,6 +58,8 @@ def self.match(address) | |
# current zone, if it's a state zone. If the passed-in zone has members, it | ||
# will also be in the result set. | ||
def self.with_shared_members(zone) | ||
return none unless zone | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this work? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like it should to me. This is a class method so it will return the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What John said. However, medium term that entire method goes away when #783 gets applied. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had forgotten that |
||
|
||
states_and_state_country_ids = zone.states.pluck(:id, :country_id).to_a | ||
state_ids = states_and_state_country_ids.map(&:first) | ||
state_country_ids = states_and_state_country_ids.map(&:second) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
require 'spec_helper' | ||
|
||
RSpec.describe Spree::Tax::ItemAdjuster do | ||
subject(:adjuster) { described_class.new(item) } | ||
let(:order) { Spree::Order.new } | ||
let(:item) { Spree::LineItem.new(order: order) } | ||
|
||
before do | ||
allow(order).to receive(:tax_zone) { build(:zone) } | ||
end | ||
|
||
describe 'initialization' do | ||
it 'sets order to item order' do | ||
expect(adjuster.order).to eq(item.order) | ||
end | ||
|
||
it 'sets adjustable' do | ||
expect(adjuster.item).to eq(item) | ||
end | ||
end | ||
|
||
describe '#adjust!' do | ||
before do | ||
expect(order).to receive(:tax_zone).and_return(tax_zone) | ||
end | ||
|
||
context 'when the order has no tax zone' do | ||
let(:tax_zone) { nil } | ||
|
||
before do | ||
allow(order).to receive(:tax_zone).and_return(nil) | ||
adjuster.adjust! | ||
end | ||
|
||
it 'returns nil early' do | ||
expect(adjuster.adjust!).to be_nil | ||
end | ||
end | ||
|
||
context 'when the order has a tax zone' do | ||
let(:item) { build_stubbed :line_item, order: order } | ||
let(:tax_zone) { build_stubbed(:zone, :with_country) } | ||
|
||
before do | ||
expect(item).to receive(:update_column) | ||
|
||
expect(Spree::TaxRate).to receive(:for_zone).with(tax_zone).and_return(rates_for_order_zone) | ||
expect(Spree::TaxRate).to receive(:for_zone).with(Spree::Zone.default_tax).and_return([]) | ||
end | ||
|
||
context 'when there are no matching rates' do | ||
let(:rates_for_order_zone) { [] } | ||
|
||
it 'returns no adjustments' do | ||
expect(adjuster.adjust!).to eq([]) | ||
end | ||
end | ||
|
||
context 'when there are matching rates for the zone' do | ||
context 'and all rates have the same tax category as the item' do | ||
let(:item_tax_category) { build_stubbed(:tax_category) } | ||
let(:rate_1) { create :tax_rate, tax_category: item_tax_category } | ||
let(:rate_2) { create :tax_rate } | ||
let(:rates_for_order_zone) { [rate_1, rate_2] } | ||
|
||
before { allow(item).to receive(:tax_category).and_return(item_tax_category) } | ||
|
||
it 'creates an adjustment for every matching rate' do | ||
expect(rate_1).to receive_message_chain(:adjustments, :create!) | ||
expect(adjuster.adjust!.length).to eq(1) | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
require 'spec_helper' | ||
|
||
RSpec.describe Spree::Tax::OrderAdjuster do | ||
subject(:adjuster) { described_class.new(order) } | ||
|
||
describe 'initialization' do | ||
let(:order) { Spree::Order.new } | ||
|
||
it 'sets order to adjustable' do | ||
expect(adjuster.order).to eq(order) | ||
end | ||
end | ||
|
||
describe '#adjust!' do | ||
let(:zone) { build_stubbed(:zone) } | ||
let(:line_items) { build_stubbed_list(:line_item, 2) } | ||
let(:order) { build_stubbed(:order, line_items: line_items) } | ||
let(:rates_for_order_zone) { [] } | ||
let(:item_adjuster) { Spree::Tax::ItemAdjuster.new(line_items.first) } | ||
|
||
before do | ||
expect(order).to receive(:tax_zone).at_least(:once).and_return(zone) | ||
expect(Spree::TaxRate).to receive(:for_zone).with(zone).and_return(rates_for_order_zone) | ||
expect(Spree::TaxRate).to receive(:for_zone).with(Spree::Zone.default_tax).and_return([]) | ||
end | ||
|
||
it 'calls the item adjuster with all line items' do | ||
expect(Spree::Tax::ItemAdjuster).to receive(:new).with(line_items.first, rates_for_order_zone: rates_for_order_zone).and_return(item_adjuster) | ||
expect(Spree::Tax::ItemAdjuster).to receive(:new).with(line_items.second, rates_for_order_zone: rates_for_order_zone).and_return(item_adjuster) | ||
|
||
expect(item_adjuster).to receive(:adjust!).twice | ||
adjuster.adjust! | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not clear where this is being used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are used in private methods of the included module
TaxHelpers
, in order to run the database calls that only have to run once per order only once per order: https://github.com/magiclabs/solidus/blob/add-tax-adjuster/core/app/models/spree/tax/tax_helpers.rb#L28-L38.