From aa46a4b5da84ed1487467456c11aba1c7f531eee Mon Sep 17 00:00:00 2001 From: Luis Ramos Date: Fri, 7 Aug 2020 18:44:04 +0100 Subject: [PATCH] Bring models related to taxes and adjustments from spree_core --- app/models/spree/adjustment.rb | 121 ++++++++++++++ app/models/spree/calculator.rb | 39 +++++ app/models/spree/tax_category.rb | 18 +++ app/models/spree/tax_rate.rb | 86 ++++++++++ spec/models/spree/adjustment_spec.rb | 210 +++++++++++++++++++++++++ spec/models/spree/tax_category_spec.rb | 27 ++++ 6 files changed, 501 insertions(+) create mode 100644 app/models/spree/adjustment.rb create mode 100644 app/models/spree/calculator.rb create mode 100644 app/models/spree/tax_category.rb create mode 100644 app/models/spree/tax_rate.rb create mode 100644 spec/models/spree/tax_category_spec.rb diff --git a/app/models/spree/adjustment.rb b/app/models/spree/adjustment.rb new file mode 100644 index 0000000000..9168d55429 --- /dev/null +++ b/app/models/spree/adjustment.rb @@ -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 diff --git a/app/models/spree/calculator.rb b/app/models/spree/calculator.rb new file mode 100644 index 0000000000..b9aeed3333 --- /dev/null +++ b/app/models/spree/calculator.rb @@ -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 diff --git a/app/models/spree/tax_category.rb b/app/models/spree/tax_category.rb new file mode 100644 index 0000000000..6e0dd27da1 --- /dev/null +++ b/app/models/spree/tax_category.rb @@ -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 diff --git a/app/models/spree/tax_rate.rb b/app/models/spree/tax_rate.rb new file mode 100644 index 0000000000..fde950b3cd --- /dev/null +++ b/app/models/spree/tax_rate.rb @@ -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 diff --git a/spec/models/spree/adjustment_spec.rb b/spec/models/spree/adjustment_spec.rb index 0ea3eea273..123ece3534 100644 --- a/spec/models/spree/adjustment_spec.rb +++ b/spec/models/spree/adjustment_spec.rb @@ -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 diff --git a/spec/models/spree/tax_category_spec.rb b/spec/models/spree/tax_category_spec.rb new file mode 100644 index 0000000000..ea2c329aea --- /dev/null +++ b/spec/models/spree/tax_category_spec.rb @@ -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