Bring models related to taxes and adjustments from spree_core

This commit is contained in:
Luis Ramos
2020-08-07 18:44:04 +01:00
parent 60e241b2c8
commit aa46a4b5da
6 changed files with 501 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
# 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
belongs_to :adjustable, polymorphic: true
belongs_to :source, polymorphic: true
belongs_to :originator, polymorphic: true
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 :shipping, -> { where(originator_type: 'Spree::ShippingMethod') }
scope :optional, -> { where(mandatory: false) }
scope :eligible, -> { where(eligible: true) }
scope :charge, -> { where('amount >= 0') }
scope :credit, -> { where('amount < 0') }
scope :promotion, -> { where(originator_type: 'Spree::PromotionAction') }
scope :return_authorization, -> { where(source_type: "Spree::ReturnAuthorization") }
def promotion?
originator_type == 'Spree::PromotionAction'
end
# Update the boolean _eligible_ attribute which determines which adjustments
# count towards the order's adjustment_total.
def set_eligibility
result = mandatory || ((amount != 0 || promotion?) && 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 #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
private
def update_adjustable
adjustable.update! if adjustable.is_a? Order
end
end
end

View File

@@ -0,0 +1,39 @@
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
end
end

View File

@@ -0,0 +1,18 @@
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
if is_default && tax_category = self.class.where(is_default: true).first
tax_category.update_column(:is_default, false) unless tax_category == self
end
end
end
end

View File

@@ -0,0 +1,86 @@
module Spree
class DefaultTaxZoneValidator < ActiveModel::Validator
def validate(record)
if record.included_in_price
record.errors.add(:included_in_price, Spree.t(:included_price_validation)) unless Zone.default_tax
end
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 [] unless order.tax_zone
all.select do |rate|
(!rate.included_in_price && (rate.zone == order.tax_zone || rate.zone.contains?(order.tax_zone) || (order.tax_address.nil? && rate.zone.default_tax))) ||
(rate.included_in_price && !order.tax_address.nil? && !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
self.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 )
# The function returns the actual amount, which may be 0 in case of wrong setup, but is never nil
def self.default
category = TaxCategory.includes(:tax_rates).where(is_default: true).first
return 0 unless category
address ||= Address.new(country_id: Spree::Config[:default_country_id])
rate = category.tax_rates.detect { |rate| 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
end
private
def create_label
label = ""
label << (name.present? ? name : tax_category.name) + " "
label << (show_rate_in_label? ? "#{amount * 100}%" : "")
end
end
end

View File

@@ -2,6 +2,216 @@ require 'spec_helper'
module Spree
describe Adjustment do
let(:order) { mock_model(Spree::Order, update!: nil) }
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
originator.stub update_amount: true
adjustment.stub originator: originator, label: 'adjustment', amount: 0
end
it "should do nothing when closed" do
adjustment.close
originator.should_not_receive(:update_adjustment)
adjustment.update!
end
it "should do nothing when finalized" do
adjustment.finalize
originator.should_not_receive(:update_adjustment)
adjustment.update!
end
it "should set the eligibility" do
adjustment.should_receive(:set_eligibility)
adjustment.update!
end
it "should ask the originator to update_adjustment" do
originator.should_receive(:update_adjustment)
adjustment.update!
end
end
it "should do nothing when originator is nil" do
adjustment.stub originator: nil
adjustment.should_not_receive(:amount=)
adjustment.update!
end
end
context "#promotion?" do
it "returns false if not promotion adjustment" do
expect(adjustment.promotion?).to eq false
end
it "returns true if promotion adjustment" do
adjustment.originator_type = "Spree::PromotionAction"
expect(adjustment.promotion?).to eq true
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
adjustment.should be_eligible
end
it "should be eligible if `promotion?` even if not `mandatory?`" do
adjustment.should_receive(:promotion?).and_return(true)
adjustment.mandatory = false
adjustment.set_eligibility
adjustment.should be_eligible
end
it "should not be eligible unless mandatory?" do
adjustment.mandatory = false
adjustment.set_eligibility
adjustment.should_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
adjustment.should be_eligible
end
it "should be eligible if not mandatory and eligible for the originator" do
adjustment.mandatory = false
adjustment.stub(eligible_for_originator?: true)
adjustment.set_eligibility
adjustment.should be_eligible
end
it "should not be eligible if not mandatory not eligible for the originator" do
adjustment.mandatory = false
adjustment.stub(eligible_for_originator?: false)
adjustment.set_eligibility
adjustment.should_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"
)
order.should_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"
adjustment.should be_immutable
adjustment.state = "finalized"
adjustment.should be_immutable
end
it "is false when adjustment state is open" do
adjustment.state = "open"
adjustment.should_not be_immutable
end
end
context "#finalized?" do
it "is true when adjustment state is finalized" do
adjustment.state = "finalized"
adjustment.should be_finalized
end
it "is false when adjustment state isn't finalized" do
adjustment.state = "closed"
adjustment.should_not be_finalized
adjustment.state = "open"
adjustment.should_not be_finalized
end
end
end
context "#eligible_for_originator?" do
context "with no originator" do
specify { adjustment.should be_eligible_for_originator }
end
context "with originator that doesn't have 'eligible?'" do
before { adjustment.originator = mock_model(Spree::TaxRate) }
specify { adjustment.should 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 { originator.stub(eligible?: true) }
specify { adjustment.should be_eligible_for_originator }
end
context "and originator is not eligible for order" do
before { originator.stub(eligible?: false) }
specify { adjustment.should_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
adjustment.display_amount.to_s.should == "$10.55 USD"
end
end
context "with display_currency set to false" do
before { Spree::Config[:display_currency] = false }
it "does not include the currency" do
adjustment.display_amount.to_s.should == "$10.55"
end
end
context "with currency set to JPY" do
context "when adjustable is set to an order" do
before do
order.stub(:currency) { 'JPY' }
adjustment.adjustable = order
end
it "displays in JPY" do
adjustment.display_amount.to_s.should == "¥11"
end
end
context "when adjustable is nil" do
it "displays in the default currency" do
adjustment.display_amount.to_s.should == "$10.55"
end
end
end
end
context '#currency' do
it 'returns the globally configured currency' do
adjustment.currency.should == 'USD'
end
end
it "has metadata" do
adjustment = create(:adjustment, metadata: create(:adjustment_metadata))
expect(adjustment.metadata).to be

View File

@@ -0,0 +1,27 @@
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_attributes({:is_default => true})
new_tax_category.is_default.should be_true
tax_category.reload
tax_category.is_default.should be_false
end
it "should undefault the previous default tax category except when updating the existing default tax category" do
tax_category.update_column(:description, "Updated description")
tax_category.reload
tax_category.is_default.should be_true
end
end
end