mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-27 01:43:22 +00:00
Bring models related to taxes and adjustments from spree_core
This commit is contained in:
121
app/models/spree/adjustment.rb
Normal file
121
app/models/spree/adjustment.rb
Normal 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
|
||||
39
app/models/spree/calculator.rb
Normal file
39
app/models/spree/calculator.rb
Normal 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
|
||||
18
app/models/spree/tax_category.rb
Normal file
18
app/models/spree/tax_category.rb
Normal 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
|
||||
86
app/models/spree/tax_rate.rb
Normal file
86
app/models/spree/tax_rate.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
27
spec/models/spree/tax_category_spec.rb
Normal file
27
spec/models/spree/tax_category_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user