Files
openfoodnetwork/app/models/spree/tax_rate.rb

132 lines
4.2 KiB
Ruby

# 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("errors.messages.included_price_validation"))
end
end
end
module Spree
class TaxRate < ApplicationRecord
acts_as_paranoid
include CalculatedAdjustments
belongs_to :zone, class_name: "Spree::Zone", inverse_of: :tax_rates, optional: true
belongs_to :tax_category, class_name: "Spree::TaxCategory", inverse_of: :tax_rates
has_many :adjustments, as: :originator, dependent: nil
validates :amount, presence: true, numericality: 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
includes(zone: { zone_members: :zoneable }).load.select do |rate|
rate.potentially_applicable?(order.tax_zone)
end
end
def self.adjust(order, items)
applicable_rates = match(order)
applicable_tax_categories = applicable_rates.map(&:tax_category)
relevant_items, non_relevant_items = items.partition do |item|
applicable_tax_categories.include?(item.tax_category)
end
relevant_items.each do |item|
item.adjustments.tax.delete_all
relevant_rates = applicable_rates.select { |rate| rate.tax_category == item.tax_category }
relevant_rates.each do |rate|
rate.adjust(order, item)
end
end
non_relevant_items.each do |item|
if item.adjustments.tax.present?
item.adjustments.tax.delete_all
Spree::ItemAdjustments.new(item).update
end
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: DefaultCountry.id)
rate = category.tax_rates
.detect { |tax_rate| tax_rate.zone.contains_address? address }.try(:amount)
rate || 0
end
def potentially_applicable?(order_tax_zone)
# If the rate's zone matches the order's tax zone, then it's applicable.
zone == order_tax_zone ||
# If the rate's zone *contains* the order's tax zone, then it's applicable.
zone.contains?(order_tax_zone) ||
# The rate's zone is the default zone, then it's always applicable.
(included_in_price? && zone.default_tax)
end
# Creates necessary tax adjustments for the item.
def adjust(order, item)
amount = compute_amount(item)
return if amount.zero?
included = included_in_price && default_zone_or_zone_match?(order)
adjustments.create!(
adjustable: item,
amount:,
order:,
label: create_label(amount),
included:
)
end
# This method is used by Adjustment#update to recalculate the cost.
def compute_amount(item)
if included_in_price
if default_zone_or_zone_match?(item.order)
calculator.compute(item)
else
# In this case, it's a refund (for instance offering a manual discount via an adjustment)
calculator.compute(item) * - 1
end
else
calculator.compute(item)
end
end
def default_zone_or_zone_match?(order)
Zone.default_tax&.contains?(order.tax_zone) || order.tax_zone == zone
end
private
def create_label(adjustment_amount)
label = ""
label << "#{Spree.t(:refund)} " if adjustment_amount.negative?
label << "#{name.presence || tax_category.name} "
label << (show_rate_in_label? ? "#{amount * 100}%" : "")
label << " (#{I18n.t('models.tax_rate.included_in_price')})" if included_in_price?
label
end
end
end