Merge pull request #5883 from luisramos0/taxs

[Bye bye Spree] Bring models tax_rate, tax_categories, adjustments and calculator from spree_core
This commit is contained in:
Luis Ramos
2020-09-10 23:10:26 +01:00
committed by GitHub
11 changed files with 640 additions and 150 deletions

View File

@@ -0,0 +1,177 @@
# frozen_string_literal: true
require 'spree/localized_number'
require 'concerns/adjustment_scopes'
# Adjustments represent a change to the +item_total+ of an Order. Each adjustment
# has an +amount+ that can be either positive or negative.
#
# Adjustments can be open/closed/finalized
#
# Once an adjustment is finalized, it cannot be changed, but an adjustment can
# toggle between open/closed as needed
#
# Boolean attributes:
#
# +mandatory+
#
# If this flag is set to true then it means the the charge is required and will not
# be removed from the order, even if the amount is zero. In other words a record
# will be created even if the amount is zero. This is useful for representing things
# such as shipping and tax charges where you may want to make it explicitly clear
# that no charge was made for such things.
#
# +eligible?+
#
# This boolean attributes stores whether this adjustment is currently eligible
# for its order. Only eligible adjustments count towards the order's adjustment
# total. This allows an adjustment to be preserved if it becomes ineligible so
# it might be reinstated.
module Spree
class Adjustment < ActiveRecord::Base
extend Spree::LocalizedNumber
# Deletion of metadata is handled in the database.
# So we don't need the option `dependent: :destroy` as long as
# AdjustmentMetadata has no destroy logic itself.
has_one :metadata, class_name: 'AdjustmentMetadata'
belongs_to :adjustable, polymorphic: true
belongs_to :source, polymorphic: true
belongs_to :originator, polymorphic: true
belongs_to :tax_rate, -> { where spree_adjustments: { originator_type: 'Spree::TaxRate' } },
foreign_key: 'originator_id'
validates :label, presence: true
validates :amount, numericality: true
after_save :update_adjustable
after_destroy :update_adjustable
state_machine :state, initial: :open do
event :close do
transition from: :open, to: :closed
end
event :open do
transition from: :closed, to: :open
end
event :finalize do
transition from: [:open, :closed], to: :finalized
end
end
scope :tax, -> { where(originator_type: 'Spree::TaxRate', adjustable_type: 'Spree::Order') }
scope :price, -> { where(adjustable_type: 'Spree::LineItem') }
scope :optional, -> { where(mandatory: false) }
scope :charge, -> { where('amount >= 0') }
scope :credit, -> { where('amount < 0') }
scope :return_authorization, -> { where(source_type: "Spree::ReturnAuthorization") }
scope :enterprise_fee, -> { where(originator_type: 'EnterpriseFee') }
scope :admin, -> { where(source_type: nil, originator_type: nil) }
scope :included_tax, -> {
where(originator_type: 'Spree::TaxRate', adjustable_type: 'Spree::LineItem')
}
scope :with_tax, -> { where('spree_adjustments.included_tax <> 0') }
scope :without_tax, -> { where('spree_adjustments.included_tax = 0') }
scope :payment_fee, -> { where(AdjustmentScopes::PAYMENT_FEE_SCOPE) }
scope :shipping, -> { where(AdjustmentScopes::SHIPPING_SCOPE) }
scope :eligible, -> { where(AdjustmentScopes::ELIGIBLE_SCOPE) }
localize_number :amount
# Update the boolean _eligible_ attribute which determines which adjustments
# count towards the order's adjustment_total.
def set_eligibility
result = mandatory || (amount != 0 && eligible_for_originator?)
update_column(:eligible, result)
end
# Allow originator of the adjustment to perform an additional eligibility of the adjustment
# Should return _true_ if originator is absent or doesn't implement _eligible?_
def eligible_for_originator?
return true if originator.nil?
!originator.respond_to?(:eligible?) || originator.eligible?(source)
end
# Update both the eligibility and amount of the adjustment. Adjustments
# delegate updating of amount to their Originator when present, but only if
# +locked+ is false. Adjustments that are +locked+ will never change their amount.
#
# Adjustments delegate updating of amount to their Originator when present,
# but only if when they're in "open" state, closed or finalized adjustments
# are not recalculated.
#
# It receives +calculable+ as the updated source here so calculations can be
# performed on the current values of that source. If we used +source+ it
# could load the old record from db for the association. e.g. when updating
# more than on line items at once via accepted_nested_attributes the order
# object on the association would be in a old state and therefore the
# adjustment calculations would not performed on proper values
def update!(calculable = nil)
return if immutable?
# Fix for Spree issue #3381
# If we attempt to call 'source' before the reload, then source is currently
# the order object. After calling a reload, the source is the Shipment.
reload
originator.update_adjustment(self, calculable || source) if originator.present?
set_eligibility
end
def currency
adjustable ? adjustable.currency : Spree::Config[:currency]
end
def display_amount
Spree::Money.new(amount, currency: currency)
end
def immutable?
state != "open"
end
def set_included_tax!(rate)
tax = amount - (amount / (1 + rate))
set_absolute_included_tax! tax
end
def set_absolute_included_tax!(tax)
# This rubocop issue can now fixed by renaming Adjustment#update! to something else,
# then AR's update! can be used instead of update_attributes!
# rubocop:disable Rails/ActiveRecordAliases
update_attributes! included_tax: tax.round(2)
# rubocop:enable Rails/ActiveRecordAliases
end
def display_included_tax
Spree::Money.new(included_tax, currency: currency)
end
def has_tax?
included_tax.positive?
end
def self.without_callbacks
skip_callback :save, :after, :update_adjustable
skip_callback :destroy, :after, :update_adjustable
result = yield
ensure
set_callback :save, :after, :update_adjustable
set_callback :destroy, :after, :update_adjustable
result
end
private
def update_adjustable
adjustable.update! if adjustable.is_a? Order
end
end
end

View File

@@ -1,62 +0,0 @@
require 'spree/localized_number'
require 'concerns/adjustment_scopes'
module Spree
Adjustment.class_eval do
extend Spree::LocalizedNumber
# Deletion of metadata is handled in the database.
# So we don't need the option `dependent: :destroy` as long as
# AdjustmentMetadata has no destroy logic itself.
has_one :metadata, class_name: 'AdjustmentMetadata'
belongs_to :tax_rate, -> { where spree_adjustments: { originator_type: 'Spree::TaxRate' } },
foreign_key: 'originator_id'
scope :enterprise_fee, -> { where(originator_type: 'EnterpriseFee') }
scope :admin, -> { where(source_type: nil, originator_type: nil) }
scope :included_tax, -> {
where(originator_type: 'Spree::TaxRate', adjustable_type: 'Spree::LineItem')
}
scope :with_tax, -> { where('spree_adjustments.included_tax <> 0') }
scope :without_tax, -> { where('spree_adjustments.included_tax = 0') }
scope :payment_fee, -> { where(AdjustmentScopes::PAYMENT_FEE_SCOPE) }
scope :shipping, -> { where(AdjustmentScopes::SHIPPING_SCOPE) }
scope :eligible, -> { where(AdjustmentScopes::ELIGIBLE_SCOPE) }
localize_number :amount
def set_included_tax!(rate)
tax = amount - (amount / (1 + rate))
set_absolute_included_tax! tax
end
def set_absolute_included_tax!(tax)
# This rubocop issue can only be fixed when Adjustment#update! is brought from Spree to OFN
# and renamed to something else, then AR's update! can be used instead of update_attributes!
# rubocop:disable Rails/ActiveRecordAliases
update_attributes! included_tax: tax.round(2)
# rubocop:enable Rails/ActiveRecordAliases
end
def display_included_tax
Spree::Money.new(included_tax, currency: currency)
end
def has_tax?
included_tax > 0
end
def self.without_callbacks
skip_callback :save, :after, :update_adjustable
skip_callback :destroy, :after, :update_adjustable
result = yield
ensure
set_callback :save, :after, :update_adjustable
set_callback :destroy, :after, :update_adjustable
result
end
end
end

View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true
module Spree
class Calculator < ActiveRecord::Base
belongs_to :calculable, polymorphic: true
# This method must be overriden in concrete calculator.
#
# It should return amount computed based on #calculable and/or optional parameter
def compute(_something = nil)
raise NotImplementedError, 'please use concrete calculator'
end
# overwrite to provide description for your calculators
def self.description
'Base Calculator'
end
###################################################################
def self.register(*klasses); end
# Returns all calculators applicable for kind of work
def self.calculators
Rails.application.config.spree.calculators
end
def to_s
self.class.name.titleize.gsub("Calculator\/", "")
end
def description
self.class.description
end
def available?(_object)
true
end
private
# Given an object which might be an Order or a LineItem (amongst
# others), return a collection of line items.
def line_items_for(object)
if object.is_a?(Spree::LineItem)
[object]
elsif object.respond_to? :line_items
object.line_items
elsif object.respond_to?(:order) && object.order.present?
object.order.line_items
else
[object]
end
end
end
end

View File

@@ -1,19 +0,0 @@
module Spree
Calculator.class_eval do
private
# Given an object which might be an Order or a LineItem (amongst
# others), return a collection of line items.
def line_items_for(object)
if object.is_a?(Spree::LineItem)
[object]
elsif object.respond_to? :line_items
object.line_items
elsif object.respond_to?(:order) && object.order.present?
object.order.line_items
else
[object]
end
end
end
end

View File

@@ -179,11 +179,11 @@ module Spree
# For more information, please see Spree::Payment#set_unique_identifier
order_id: gateway_order_id }
options.merge!({ shipping: order.ship_total * 100,
tax: order.tax_total * 100,
subtotal: order.item_total * 100,
discount: order.promo_total * 100,
currency: currency })
options.merge!(shipping: order.ship_total * 100,
tax: order.tax_total * 100,
subtotal: order.item_total * 100,
discount: 0,
currency: currency)
options.merge!({ billing_address: order.bill_address.try(:active_merchant_hash),
shipping_address: order.ship_address.try(:active_merchant_hash) })

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
module Spree
class TaxCategory < ActiveRecord::Base
acts_as_paranoid
validates :name, presence: true, uniqueness: { scope: :deleted_at }
has_many :tax_rates, dependent: :destroy
before_save :set_default_category
def set_default_category
# set existing default tax category to false if this one has been marked as default
return unless is_default && tax_category = self.class.find_by(is_default: true)
tax_category.update_column(:is_default, false) unless tax_category == self
end
end
end

View File

@@ -0,0 +1,132 @@
# frozen_string_literal: false
module Spree
class DefaultTaxZoneValidator < ActiveModel::Validator
def validate(record)
return unless record.included_in_price
return if Zone.default_tax
record.errors.add(:included_in_price, Spree.t(:included_price_validation))
end
end
end
module Spree
class TaxRate < ActiveRecord::Base
acts_as_paranoid
include Spree::Core::CalculatedAdjustments
belongs_to :zone, class_name: "Spree::Zone"
belongs_to :tax_category, class_name: "Spree::TaxCategory"
validates :amount, presence: true, numericality: true
validates :tax_category_id, presence: true
validates_with DefaultTaxZoneValidator
scope :by_zone, ->(zone) { where(zone_id: zone) }
# Gets the array of TaxRates appropriate for the specified order
def self.match(order)
return [] if order.distributor && !order.distributor.charges_sales_tax
return [] unless order.tax_zone
all.select do |rate|
rate.zone == order.tax_zone || rate.zone.contains?(order.tax_zone) || rate.zone.default_tax
end
end
def self.adjust(order)
order.adjustments.tax.destroy_all
order.line_item_adjustments.where(originator_type: 'Spree::TaxRate').destroy_all
match(order).each do |rate|
rate.adjust(order)
end
end
# For VAT, the default rate is the rate that is configured for the default category
# It is needed for every price calculation (as all customer facing prices include VAT)
# Here we return the actual amount, which may be 0 in case of wrong setup, but is never nil
def self.default
category = TaxCategory.includes(:tax_rates).find_by(is_default: true)
return 0 unless category
address ||= Address.new(country_id: Spree::Config[:default_country_id])
rate = category.tax_rates.detect { |tax_rate| tax_rate.zone.include? address }.try(:amount)
rate || 0
end
# Creates necessary tax adjustments for the order.
def adjust(order)
label = create_label
if included_in_price
if Zone.default_tax.contains? order.tax_zone
order.line_items.each { |line_item| create_adjustment(label, line_item, line_item) }
else
amount = -1 * calculator.compute(order)
label = Spree.t(:refund) + label
order.adjustments.create(
amount: amount,
source: order,
originator: self,
state: "closed",
label: label
)
end
else
create_adjustment(label, order, order)
end
order.adjustments(:reload)
order.line_items(:reload)
# TaxRate adjustments (order.adjustments.tax)
# and price adjustments (tax included on line items) consist of 100% tax
(order.adjustments.tax + order.price_adjustments).each do |adjustment|
adjustment.set_absolute_included_tax! adjustment.amount
end
end
# Manually apply a TaxRate to a particular amount. TaxRates normally compute against
# LineItems or Orders, so we mock out a line item here to fit the interface
# that our calculator (usually DefaultTax) expects.
def compute_tax(amount)
line_item = LineItem.new quantity: 1
line_item.tax_category = tax_category
line_item.define_singleton_method(:price) { amount }
# Tax on adjustments (represented by the included_tax field) is always inclusive of
# tax. However, there's nothing to stop an admin from setting one up with a tax rate
# that's marked as not inclusive of tax, and that would result in the DefaultTax
# calculator generating a slightly incorrect value. Therefore, we treat the tax
# rate as inclusive of tax for the calculations below, regardless of its original
# setting.
with_tax_included_in_price do
calculator.compute line_item
end
end
private
def create_label
label = ""
label << (name.presence || tax_category.name) + " "
label << (show_rate_in_label? ? "#{amount * 100}%" : "")
end
def with_tax_included_in_price
old_included_in_price = included_in_price
self.included_in_price = true
calculator.calculable.included_in_price = true
result = yield
ensure
self.included_in_price = old_included_in_price
calculator.calculable.included_in_price = old_included_in_price
result
end
end
end

View File

@@ -1,61 +0,0 @@
module Spree
TaxRate.class_eval do
class << self
def match(order)
return [] if order.distributor && !order.distributor.charges_sales_tax
return [] unless order.tax_zone
all.select do |rate|
rate.zone == order.tax_zone || rate.zone.contains?(order.tax_zone) || rate.zone.default_tax
end
end
end
def adjust_with_included_tax(order)
adjust_without_included_tax(order)
order.adjustments(:reload)
order.line_items(:reload)
# TaxRate adjustments (order.adjustments.tax) and price adjustments (tax included on line items) consist of 100% tax
(order.adjustments.tax + order.price_adjustments).each do |adjustment|
adjustment.set_absolute_included_tax! adjustment.amount
end
end
alias_method_chain :adjust, :included_tax
# Manually apply a TaxRate to a particular amount. TaxRates normally compute against
# LineItems or Orders, so we mock out a line item here to fit the interface
# that our calculator (usually DefaultTax) expects.
def compute_tax(amount)
line_item = LineItem.new quantity: 1
line_item.tax_category = tax_category
line_item.define_singleton_method(:price) { amount }
# Tax on adjustments (represented by the included_tax field) is always inclusive of
# tax. However, there's nothing to stop an admin from setting one up with a tax rate
# that's marked as not inclusive of tax, and that would result in the DefaultTax
# calculator generating a slightly incorrect value. Therefore, we treat the tax
# rate as inclusive of tax for the calculations below, regardless of its original
# setting.
with_tax_included_in_price do
calculator.compute line_item
end
end
private
def with_tax_included_in_price
old_included_in_price = included_in_price
self.included_in_price = true
calculator.calculable.included_in_price = true
result = yield
ensure
self.included_in_price = old_included_in_price
calculator.calculable.included_in_price = old_included_in_price
result
end
end
end

View File

@@ -0,0 +1,9 @@
FactoryBot.define do
factory :return_authorization, class: Spree::ReturnAuthorization do
number '100'
amount 100.00
association(:order, factory: :shipped_order)
reason 'no particular reason'
state 'received'
end
end

View File

@@ -2,6 +2,212 @@ require 'spec_helper'
module Spree
describe Adjustment do
let(:order) { build(:order) }
let(:adjustment) { Spree::Adjustment.create(label: "Adjustment", amount: 5) }
describe "scopes" do
let!(:arbitrary_adjustment) { create(:adjustment, source: nil, label: "Arbitrary") }
let!(:return_authorization_adjustment) { create(:adjustment, source: create(:return_authorization)) }
it "returns return_authorization adjustments" do
expect(Spree::Adjustment.return_authorization.to_a).to eq [return_authorization_adjustment]
end
end
context "#update!" do
context "when originator present" do
let(:originator) { double("originator", update_adjustment: nil) }
before do
allow(originator).to receive_messages update_amount: true
allow(adjustment).to receive_messages originator: originator, label: 'adjustment', amount: 0
end
it "should do nothing when closed" do
adjustment.close
expect(originator).not_to receive(:update_adjustment)
adjustment.update!
end
it "should do nothing when finalized" do
adjustment.finalize
expect(originator).not_to receive(:update_adjustment)
adjustment.update!
end
it "should set the eligibility" do
expect(adjustment).to receive(:set_eligibility)
adjustment.update!
end
it "should ask the originator to update_adjustment" do
expect(originator).to receive(:update_adjustment)
adjustment.update!
end
end
it "should do nothing when originator is nil" do
allow(adjustment).to receive_messages originator: nil
expect(adjustment).not_to receive(:amount=)
adjustment.update!
end
end
context "#eligible? after #set_eligibility" do
context "when amount is 0" do
before { adjustment.amount = 0 }
it "should be eligible if mandatory?" do
adjustment.mandatory = true
adjustment.set_eligibility
expect(adjustment).to be_eligible
end
it "should not be eligible unless mandatory?" do
adjustment.mandatory = false
adjustment.set_eligibility
expect(adjustment).to_not be_eligible
end
end
context "when amount is greater than 0" do
before { adjustment.amount = 25.00 }
it "should be eligible if mandatory?" do
adjustment.mandatory = true
adjustment.set_eligibility
expect(adjustment).to be_eligible
end
it "should be eligible if not mandatory and eligible for the originator" do
adjustment.mandatory = false
allow(adjustment).to receive_messages(eligible_for_originator?: true)
adjustment.set_eligibility
expect(adjustment).to be_eligible
end
it "should not be eligible if not mandatory not eligible for the originator" do
adjustment.mandatory = false
allow(adjustment).to receive_messages(eligible_for_originator?: false)
adjustment.set_eligibility
expect(adjustment).to_not be_eligible
end
end
end
context "#save" do
it "should call order#update!" do
adjustment = Spree::Adjustment.new(
adjustable: order,
amount: 10,
label: "Foo"
)
expect(order).to receive(:update!)
adjustment.save
end
end
context "adjustment state" do
let(:adjustment) { create(:adjustment, state: 'open') }
context "#immutable?" do
it "is true when adjustment state isn't open" do
adjustment.state = "closed"
expect(adjustment).to be_immutable
adjustment.state = "finalized"
expect(adjustment).to be_immutable
end
it "is false when adjustment state is open" do
adjustment.state = "open"
expect(adjustment).to_not be_immutable
end
end
context "#finalized?" do
it "is true when adjustment state is finalized" do
adjustment.state = "finalized"
expect(adjustment).to be_finalized
end
it "is false when adjustment state isn't finalized" do
adjustment.state = "closed"
expect(adjustment).to_not be_finalized
adjustment.state = "open"
expect(adjustment).to_not be_finalized
end
end
end
context "#eligible_for_originator?" do
context "with no originator" do
specify { expect(adjustment).to be_eligible_for_originator }
end
context "with originator that doesn't have 'eligible?'" do
before { adjustment.originator = build(:tax_rate) }
specify { expect(adjustment).to be_eligible_for_originator }
end
context "with originator that has 'eligible?'" do
let(:originator) { Spree::TaxRate.new }
before { adjustment.originator = originator }
context "and originator is eligible for order" do
before { allow(originator).to receive_messages(eligible?: true) }
specify { expect(adjustment).to be_eligible_for_originator }
end
context "and originator is not eligible for order" do
before { allow(originator).to receive_messages(eligible?: false) }
specify { expect(adjustment).to_not be_eligible_for_originator }
end
end
end
context "#display_amount" do
before { adjustment.amount = 10.55 }
context "with display_currency set to true" do
before { Spree::Config[:display_currency] = true }
it "shows the currency" do
expect(adjustment.display_amount.to_s).to eq "$10.55 #{Spree::Config[:currency]}"
end
end
context "with display_currency set to false" do
before { Spree::Config[:display_currency] = false }
it "does not include the currency" do
expect(adjustment.display_amount.to_s).to eq "$10.55"
end
end
context "with currency set to JPY" do
context "when adjustable is set to an order" do
before do
allow(order).to receive(:currency) { 'JPY' }
adjustment.adjustable = order
end
it "displays in JPY" do
expect(adjustment.display_amount.to_s).to eq "¥11"
end
end
context "when adjustable is nil" do
it "displays in the default currency" do
expect(adjustment.display_amount.to_s).to eq "$10.55"
end
end
end
end
context '#currency' do
it 'returns the globally configured currency' do
expect(adjustment.currency).to eq Spree::Config[:currency]
end
end
it "has metadata" do
adjustment = create(:adjustment, metadata: create(:adjustment_metadata))
expect(adjustment.metadata).to be
@@ -39,7 +245,7 @@ module Spree
let!(:zone) { create(:zone_with_member) }
let!(:order) { create(:order, bill_address: create(:address)) }
let!(:line_item) { create(:line_item, order: order) }
let(:tax_rate) { create(:tax_rate, included_in_price: true, calculator: Calculator::FlatRate.new(preferred_amount: 0.1)) }
let(:tax_rate) { create(:tax_rate, included_in_price: true, calculator: ::Calculator::FlatRate.new(preferred_amount: 0.1)) }
let(:adjustment) { line_item.adjustments(:reload).first }
before do
@@ -205,8 +411,10 @@ module Spree
end
context "when enterprise fees inherit their tax_category from the product they are applied to" do
let(:product_tax_rate) { create(:tax_rate, included_in_price: true, calculator: ::Calculator::DefaultTax.new, zone: zone, amount: 0.2) }
let(:product_tax_category) { create(:tax_category, tax_rates: [product_tax_rate]) }
let(:product_tax_rate) {
create(:tax_rate, included_in_price: true, calculator: ::Calculator::DefaultTax.new, zone: zone, amount: 0.2)
}
let(:product_tax_category) { create(:tax_category, tax_rates: [product_tax_rate]) }
before do
variant.product.update_attribute(:tax_category_id, product_tax_category.id)

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'spec_helper'
describe Spree::TaxCategory do
context 'default tax category' do
let(:tax_category) { create(:tax_category) }
let(:new_tax_category) { create(:tax_category) }
before do
tax_category.update_column(:is_default, true)
end
it "should undefault the previous default tax category" do
new_tax_category.update({ is_default: true })
expect(new_tax_category.is_default).to be_truthy
tax_category.reload
expect(tax_category.is_default).to be_falsy
end
it "undefaults the previous default tax category
except when updating the existing default tax category" do
tax_category.update_column(:description, "Updated description")
tax_category.reload
expect(tax_category.is_default).to be_truthy
end
end
end