diff --git a/app/controllers/admin/enterprise_fees_controller.rb b/app/controllers/admin/enterprise_fees_controller.rb index 085828d7b3..c4d4a621b1 100644 --- a/app/controllers/admin/enterprise_fees_controller.rb +++ b/app/controllers/admin/enterprise_fees_controller.rb @@ -59,6 +59,7 @@ module Admin def load_data @calculators = EnterpriseFee.calculators.sort_by(&:name) + @tax_categories = Spree::TaxCategory.order('is_default DESC, name ASC') end def collection diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 655d1778dc..68048ee870 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -6,6 +6,7 @@ require 'open_food_network/order_grouper' require 'open_food_network/customers_report' require 'open_food_network/users_and_enterprises_report' require 'open_food_network/order_cycle_management_report' +require 'open_food_network/sales_tax_report' Spree::Admin::ReportsController.class_eval do @@ -75,7 +76,7 @@ Spree::Admin::ReportsController.class_eval do end def orders_and_distributors - params[:q] = {} unless params[:q] + params[:q] ||= {} if params[:q][:completed_at_gt].blank? params[:q][:completed_at_gt] = Time.zone.now.beginning_of_month @@ -102,9 +103,39 @@ Spree::Admin::ReportsController.class_eval do send_data csv_string, :filename => "orders_and_distributors_#{timestamp}.csv" end end + + def sales_tax + params[:q] ||= {} + + if params[:q][:completed_at_gt].blank? + params[:q][:completed_at_gt] = Time.zone.now.beginning_of_month + else + params[:q][:completed_at_gt] = Time.zone.parse(params[:q][:completed_at_gt]).beginning_of_day rescue Time.zone.now.beginning_of_month + end + + if params[:q] && !params[:q][:completed_at_lt].blank? + params[:q][:completed_at_lt] = Time.zone.parse(params[:q][:completed_at_lt]).end_of_day rescue "" + end + params[:q][:meta_sort] ||= "completed_at.desc" + + @search = Spree::Order.complete.not_state(:canceled).managed_by(spree_current_user).search(params[:q]) + orders = @search.result + @distributors = Enterprise.is_distributor.managed_by(spree_current_user) + + @report = OpenFoodNetwork::SalesTaxReport.new orders + unless params[:csv] + render :html => @report + else + csv_string = CSV.generate do |csv| + csv << @report.header + @report.table.each { |row| csv << row } + end + send_data csv_string, :filename => "sales_tax.csv" + end + end def bulk_coop - params[:q] = {} unless params[:q] + params[:q] ||= {} if params[:q][:completed_at_gt].blank? params[:q][:completed_at_gt] = Time.zone.now.beginning_of_month @@ -257,7 +288,7 @@ Spree::Admin::ReportsController.class_eval do end def payments - params[:q] = {} unless params[:q] + params[:q] ||= {} if params[:q][:completed_at_gt].blank? params[:q][:completed_at_gt] = Time.zone.now.beginning_of_month @@ -641,7 +672,8 @@ Spree::Admin::ReportsController.class_eval do :products_and_inventory => {:name => "Products & Inventory", :description => ''}, :sales_total => { :name => "Sales Total", :description => "Sales Total For All Orders" }, :users_and_enterprises => { :name => "Users & Enterprises", :description => "Enterprise Ownership & Status" }, - :order_cycle_management => {:name => "Order Cycle Management", :description => ''} + :order_cycle_management => {:name => "Order Cycle Management", :description => ''}, + :sales_tax => { :name => "Sales Tax", :description => "Sales Tax For Orders" } } # Return only reports the user is authorized to view. reports.select { |action| can? action, :report } diff --git a/app/models/enterprise_fee.rb b/app/models/enterprise_fee.rb index 6270f77b7a..730bac0787 100644 --- a/app/models/enterprise_fee.rb +++ b/app/models/enterprise_fee.rb @@ -1,5 +1,6 @@ class EnterpriseFee < ActiveRecord::Base belongs_to :enterprise + belongs_to :tax_category, class_name: 'Spree::TaxCategory', foreign_key: 'tax_category_id' has_and_belongs_to_many :order_cycles, join_table: 'coordinator_fees' has_many :exchange_fees, dependent: :destroy has_many :exchanges, through: :exchange_fees @@ -8,7 +9,7 @@ class EnterpriseFee < ActiveRecord::Base calculated_adjustments - attr_accessible :enterprise_id, :fee_type, :name, :calculator_type + attr_accessible :enterprise_id, :fee_type, :name, :tax_category_id, :calculator_type FEE_TYPES = %w(packing transport admin sales fundraising) PER_ORDER_CALCULATORS = ['Spree::Calculator::FlatRate', 'Spree::Calculator::FlexiRate'] diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index 9bff908346..145d8310de 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -153,7 +153,7 @@ class AbilityDecorator end # Reports page - can [:admin, :index, :customers, :orders_and_distributors, :group_buys, :bulk_coop, :payments, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management], :report + can [:admin, :index, :customers, :group_buys, :bulk_coop, :sales_tax, :payments, :orders_and_distributors, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management], :report end diff --git a/app/models/spree/adjustment_decorator.rb b/app/models/spree/adjustment_decorator.rb index 2c5bb0be0a..836080183c 100644 --- a/app/models/spree/adjustment_decorator.rb +++ b/app/models/spree/adjustment_decorator.rb @@ -3,5 +3,17 @@ module Spree has_one :metadata, class_name: 'AdjustmentMetadata', dependent: :destroy scope :enterprise_fee, where(originator_type: 'EnterpriseFee') + scope :included_tax, where(originator_type: 'Spree::TaxRate', adjustable_type: 'Spree::LineItem') + + attr_accessible :included_tax + + def set_included_tax!(rate) + tax = amount - (amount / (1 + rate)) + set_absolute_included_tax! tax + end + + def set_absolute_included_tax!(tax) + update_attributes! included_tax: tax.round(2) + end end end diff --git a/app/models/spree/app_configuration_decorator.rb b/app/models/spree/app_configuration_decorator.rb index f4f4acc5ec..5ad4a9a1c5 100644 --- a/app/models/spree/app_configuration_decorator.rb +++ b/app/models/spree/app_configuration_decorator.rb @@ -1,9 +1,10 @@ Spree::AppConfiguration.class_eval do - # This file decorates the existing preferences file defined by Spree. - # It allows us to add our own global configuration variables, which - # we can allow to be modified in the UI by adding appropriate form - # elements to existing or new configuration pages. + # This file decorates the existing preferences file defined by Spree. + # It allows us to add our own global configuration variables, which + # we can allow to be modified in the UI by adding appropriate form + # elements to existing or new configuration pages. - # Tax Preferences - preference :products_require_tax_category, :boolean, default: false -end \ No newline at end of file + # Tax Preferences + preference :products_require_tax_category, :boolean, default: false + preference :shipping_tax_rate, :decimal, default: 0 +end diff --git a/app/models/spree/money_decorator.rb b/app/models/spree/money_decorator.rb new file mode 100644 index 0000000000..e179343bc5 --- /dev/null +++ b/app/models/spree/money_decorator.rb @@ -0,0 +1,7 @@ +Spree::Money.class_eval do + + # return the currency symbol (on it's own) for the current default currency + def self.currency_symbol + Money.new(0, Spree::Config[:currency]).symbol + end +end diff --git a/app/models/spree/shipment_decorator.rb b/app/models/spree/shipment_decorator.rb new file mode 100644 index 0000000000..ee9189efdd --- /dev/null +++ b/app/models/spree/shipment_decorator.rb @@ -0,0 +1,11 @@ +module Spree + Shipment.class_eval do + def ensure_correct_adjustment_with_included_tax + ensure_correct_adjustment_without_included_tax + + adjustment.set_included_tax! Config.shipping_tax_rate if Config.shipment_inc_vat + end + + alias_method_chain :ensure_correct_adjustment, :included_tax + end +end diff --git a/app/models/spree/tax_rate_decorator.rb b/app/models/spree/tax_rate_decorator.rb new file mode 100644 index 0000000000..e41c20db70 --- /dev/null +++ b/app/models/spree/tax_rate_decorator.rb @@ -0,0 +1,11 @@ +Spree::TaxRate.class_eval do + def adjust_with_included_tax(order) + adjust_without_included_tax(order) + + (order.adjustments.tax + order.price_adjustments).each do |a| + a.set_absolute_included_tax! a.amount + end + end + + alias_method_chain :adjust, :included_tax +end diff --git a/app/overrides/spree/admin/tax_settings/edit/shipping_tax_rate.html.haml.deface b/app/overrides/spree/admin/tax_settings/edit/shipping_tax_rate.html.haml.deface new file mode 100644 index 0000000000..b378ba84a6 --- /dev/null +++ b/app/overrides/spree/admin/tax_settings/edit/shipping_tax_rate.html.haml.deface @@ -0,0 +1,5 @@ +/ insert_after "[data-hook='shipment_vat']" + +.field.align-center{ "data-hook" => "shipping_tax_rate" } + = number_field_tag "preferences[shipping_tax_rate]", Spree::Config[:shipping_tax_rate].to_f, in: 0.0..1.0, step: 0.01 + = label_tag nil, t(:shipping_tax_rate) \ No newline at end of file diff --git a/app/presenters/enterprise_fee_presenter.rb b/app/presenters/enterprise_fee_presenter.rb index a0a6d8460a..b8f9ae4655 100644 --- a/app/presenters/enterprise_fee_presenter.rb +++ b/app/presenters/enterprise_fee_presenter.rb @@ -3,7 +3,7 @@ class EnterpriseFeePresenter @controller, @enterprise_fee, @index = controller, enterprise_fee, index end - delegate :id, :enterprise_id, :fee_type, :name, :calculator_type, :to => :enterprise_fee + delegate :id, :enterprise_id, :fee_type, :name, :tax_category_id, :calculator_type, :to => :enterprise_fee def enterprise_fee @enterprise_fee diff --git a/app/views/admin/enterprise_fees/index.html.haml b/app/views/admin/enterprise_fees/index.html.haml index ba3c3e6f4c..2237ca8a43 100644 --- a/app/views/admin/enterprise_fees/index.html.haml +++ b/app/views/admin/enterprise_fees/index.html.haml @@ -13,6 +13,7 @@ %th Enterprise %th Fee Type %th Name + %th Tax Category %th Calculator %th Calculator values %th.actions @@ -24,6 +25,7 @@ = f.ng_collection_select :enterprise_id, @enterprises, :id, :name, 'enterprise_fee.enterprise_id', :include_blank => true %td= f.ng_select :fee_type, enterprise_fee_type_options, 'enterprise_fee.fee_type' %td= f.ng_text_field :name, { placeholder: 'e.g. packing fee' } + %td= f.ng_collection_select :tax_category_id, @tax_categories, :id, :name, 'enterprise_fee.tax_category_id' %td= f.ng_collection_select :calculator_type, @calculators, :name, :description, 'enterprise_fee.calculator_type', {'class' => 'calculator_type', 'ng-model' => 'calculatorType', 'spree-ensure-calculator-preferences-match-type' => "1"} %td{'ng-bind-html-unsafe-compiled' => 'enterprise_fee.calculator_settings'} %td.actions{'spree-delete-resource' => "1"} diff --git a/app/views/admin/enterprise_fees/index.rep b/app/views/admin/enterprise_fees/index.rep index 61192ca786..8dc24b5d74 100644 --- a/app/views/admin/enterprise_fees/index.rep +++ b/app/views/admin/enterprise_fees/index.rep @@ -4,6 +4,7 @@ r.list_of :enterprise_fees, @presented_collection do r.element :enterprise_name r.element :fee_type r.element :name + r.element :tax_category_id r.element :calculator_type r.element :calculator_description r.element :calculator_settings if @include_calculators diff --git a/app/views/spree/admin/reports/sales_tax.html.haml b/app/views/spree/admin/reports/sales_tax.html.haml new file mode 100644 index 0000000000..a7b3d9275d --- /dev/null +++ b/app/views/spree/admin/reports/sales_tax.html.haml @@ -0,0 +1,32 @@ += form_for @search, :url => spree.sales_tax_admin_reports_path do |f| + = render 'date_range_form', f: f + + .row + .four.columns.alpha + = label_tag nil, "Distributor:" + = f.collection_select(:distributor_id_eq, @distributors, :id, :name, {:include_blank => 'All'}, {:class => "select2 fullwidth"}) + = check_box_tag :csv + = label_tag :csv, "Download as csv" + %br + = button t(:search) + +%br +%br +%table#listing_orders.index + %thead + %tr{'data-hook' => "orders_header"} + - @report.header.each do |heading| + %th= heading + %tbody + - @report.table.each do |row| + %tr + - row.each_with_index do |column, i| + - if i == 0 + %td + %a.edit-order{'href' => "/admin/orders/#{column}"}= column + - else + %td= column + - if @report.table.empty? + %tr + %td{:colspan => @report.header.count}= t(:none) + diff --git a/app/views/spree/layouts/admin/_login_nav.html.haml b/app/views/spree/layouts/admin/_login_nav.html.haml index 4ecb72d148..088ac02377 100644 --- a/app/views/spree/layouts/admin/_login_nav.html.haml +++ b/app/views/spree/layouts/admin/_login_nav.html.haml @@ -5,7 +5,7 @@ \: #{spree_current_user.email} %li{"data-hook" => "user-account-link"} %i.icon-user - = link_to t(:account), spree.edit_user_path(spree_current_user) + = link_to t(:account), account_path %li{"data-hook" => "user-logout-link"} %i.icon-signout = link_to t(:logout), spree.logout_path diff --git a/config/initializers/spree.rb b/config/initializers/spree.rb index 5912e0b9ec..9c81da88ef 100644 --- a/config/initializers/spree.rb +++ b/config/initializers/spree.rb @@ -14,8 +14,12 @@ Spree.config do |config| config.checkout_zone = ENV["CHECKOUT_ZONE"] config.address_requires_state = true - country = Spree::Country.find_by_name(ENV["DEFAULT_COUNTRY"]) - config.default_country_id = country.id if country.present? + if Spree::Country.table_exists? + country = Spree::Country.find_by_name(ENV["DEFAULT_COUNTRY"]) + config.default_country_id = country.id if country.present? + else + config.default_country_id = 12 # Australia + end # -- spree_paypal_express # Auto-capture payments. Without this option, payments must be manually captured in the paypal interface. diff --git a/config/routes.rb b/config/routes.rb index e7f9f2bba0..401ecbeaf3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -123,7 +123,8 @@ Spree::Core::Engine.routes.prepend do match '/admin/reports/bulk_coop' => 'admin/reports#bulk_coop', :as => "bulk_coop_admin_reports", :via => [:get, :post] match '/admin/reports/payments' => 'admin/reports#payments', :as => "payments_admin_reports", :via => [:get, :post] match '/admin/reports/orders_and_fulfillment' => 'admin/reports#orders_and_fulfillment', :as => "orders_and_fulfillment_admin_reports", :via => [:get, :post] - match '/admin/reports/users_and_enterprises' => 'admin/reports#users_and_enterprises', :as => "users_and_enterprises_admin_reports", :via => [:get, :post] + match '/admin/reports/users_and_enterprises' => 'admin/reports#users_and_enterprises', :as => "users_and_enterprises_admin_reports", :via => [:get, :post] + match '/admin/reports/sales_tax' => 'admin/reports#sales_tax', :as => "sales_tax_admin_reports", :via => [:get, :post] match '/admin/products/bulk_edit' => 'admin/products#bulk_edit', :as => "bulk_edit_admin_products" match '/admin/orders/bulk_management' => 'admin/orders#bulk_management', :as => "admin_bulk_order_management" match '/admin/reports/products_and_inventory' => 'admin/reports#products_and_inventory', :as => "products_and_inventory_admin_reports", :via => [:get, :post] diff --git a/db/migrate/20150225111538_add_tax_category_to_enterprise_fee.rb b/db/migrate/20150225111538_add_tax_category_to_enterprise_fee.rb new file mode 100644 index 0000000000..05b5410587 --- /dev/null +++ b/db/migrate/20150225111538_add_tax_category_to_enterprise_fee.rb @@ -0,0 +1,7 @@ +class AddTaxCategoryToEnterpriseFee < ActiveRecord::Migration + def change + add_column :enterprise_fees, :tax_category_id, :integer + add_foreign_key :enterprise_fees, :spree_tax_categories, column: :tax_category_id + add_index :enterprise_fees, :tax_category_id + end +end diff --git a/db/migrate/20150225232938_add_included_tax_to_adjustments.rb b/db/migrate/20150225232938_add_included_tax_to_adjustments.rb new file mode 100644 index 0000000000..f3815dec2a --- /dev/null +++ b/db/migrate/20150225232938_add_included_tax_to_adjustments.rb @@ -0,0 +1,5 @@ +class AddIncludedTaxToAdjustments < ActiveRecord::Migration + def change + add_column :spree_adjustments, :included_tax, :decimal, precision: 10, scale: 2, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index a8ac91e6d8..d885130600 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended to check this file into your version control system. -ActiveRecord::Schema.define(:version => 20150220035501) do +ActiveRecord::Schema.define(:version => 20150225232938) do create_table "adjustment_metadata", :force => true do |t| t.integer "adjustment_id" @@ -177,11 +177,13 @@ ActiveRecord::Schema.define(:version => 20150220035501) do t.integer "enterprise_id" t.string "fee_type" t.string "name" - t.datetime "created_at", :null => false - t.datetime "updated_at", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.integer "tax_category_id" end add_index "enterprise_fees", ["enterprise_id"], :name => "index_enterprise_fees_on_enterprise_id" + add_index "enterprise_fees", ["tax_category_id"], :name => "index_enterprise_fees_on_tax_category_id" create_table "enterprise_groups", :force => true do |t| t.string "name" @@ -414,6 +416,7 @@ ActiveRecord::Schema.define(:version => 20150220035501) do t.string "originator_type" t.boolean "eligible", :default => true t.string "adjustable_type" + t.decimal "included_tax", :precision => 10, :scale => 2, :default => 0.0, :null => false end add_index "spree_adjustments", ["adjustable_id"], :name => "index_adjustments_on_order_id" @@ -1088,6 +1091,7 @@ ActiveRecord::Schema.define(:version => 20150220035501) do add_foreign_key "distributors_shipping_methods", "spree_shipping_methods", name: "distributors_shipping_methods_shipping_method_id_fk", column: "shipping_method_id" add_foreign_key "enterprise_fees", "enterprises", name: "enterprise_fees_enterprise_id_fk" + add_foreign_key "enterprise_fees", "spree_tax_categories", name: "enterprise_fees_tax_category_id_fk", column: "tax_category_id" add_foreign_key "enterprise_groups", "spree_addresses", name: "enterprise_groups_address_id_fk", column: "address_id" add_foreign_key "enterprise_groups", "spree_users", name: "enterprise_groups_owner_id_fk", column: "owner_id" diff --git a/lib/open_food_network/enterprise_fee_applicator.rb b/lib/open_food_network/enterprise_fee_applicator.rb index 45905bfaca..070cb4a91c 100644 --- a/lib/open_food_network/enterprise_fee_applicator.rb +++ b/lib/open_food_network/enterprise_fee_applicator.rb @@ -2,12 +2,18 @@ module OpenFoodNetwork class EnterpriseFeeApplicator < Struct.new(:enterprise_fee, :variant, :role) def create_line_item_adjustment(line_item) a = enterprise_fee.create_locked_adjustment(line_item_adjustment_label, line_item.order, line_item, true) + AdjustmentMetadata.create! adjustment: a, enterprise: enterprise_fee.enterprise, fee_name: enterprise_fee.name, fee_type: enterprise_fee.fee_type, enterprise_role: role + + a.set_absolute_included_tax! adjustment_tax(line_item.order, a) end def create_order_adjustment(order) a = enterprise_fee.create_locked_adjustment(order_adjustment_label, order, order, true) + AdjustmentMetadata.create! adjustment: a, enterprise: enterprise_fee.enterprise, fee_name: enterprise_fee.name, fee_type: enterprise_fee.fee_type, enterprise_role: role + + a.set_absolute_included_tax! adjustment_tax(order, a) end @@ -24,6 +30,48 @@ module OpenFoodNetwork def base_adjustment_label "#{enterprise_fee.fee_type} fee by #{role} #{enterprise_fee.enterprise.name}" end + + def adjustment_tax(order, adjustment) + tax_rates = enterprise_fee.tax_category ? enterprise_fee.tax_category.tax_rates.match(order) : [] + + tax_rates.sum do |rate| + compute_tax rate, adjustment.amount + end + end + + # 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(tax_rate, amount) + product = OpenStruct.new tax_category: tax_rate.tax_category + line_item = Spree::LineItem.new quantity: 1 + line_item.define_singleton_method(:product) { product } + line_item.define_singleton_method(:price) { amount } + + # The enterprise fee adjustments for which we're calculating tax are 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(tax_rate) do + tax_rate.calculator.compute line_item + end + end + + def with_tax_included_in_price(tax_rate) + old_included_in_price = tax_rate.included_in_price + + tax_rate.included_in_price = true + tax_rate.calculator.calculable.included_in_price = true + + result = yield + + tax_rate.included_in_price = old_included_in_price + tax_rate.calculator.calculable.included_in_price = old_included_in_price + + result + end end end diff --git a/lib/open_food_network/sales_tax_report.rb b/lib/open_food_network/sales_tax_report.rb new file mode 100644 index 0000000000..d5b69011d2 --- /dev/null +++ b/lib/open_food_network/sales_tax_report.rb @@ -0,0 +1,77 @@ +module OpenFoodNetwork + class SalesTaxReport + include Spree::ReportsHelper + + def initialize orders + @orders = orders + end + + def header + ["Order number", "Date", "Items", "Items total (#{currency_symbol})", "Taxable Items Total (#{currency_symbol})", + "Sales Tax (#{currency_symbol})", "Delivery Charge (#{currency_symbol})", "Tax on Delivery (#{currency_symbol})", + "Total Tax (#{currency_symbol})", "Customer", "Distributor"] + end + + def table + @orders.map do |order| + totals = totals_of order.line_items + shipping_cost = shipping_cost_for order + shipping_tax = shipping_tax_on shipping_cost + + [order.number, order.created_at, totals[:items], totals[:items_total], + totals[:taxable_total], totals[:sales_tax], shipping_cost, shipping_tax, totals[:sales_tax] + shipping_tax, + order.bill_address.full_name, order.distributor.andand.name] + end + end + + + private + + def totals_of(line_items) + totals = {items: 0, items_total: 0.0, taxable_total: 0.0, sales_tax: 0.0} + + line_items.each do |line_item| + totals[:items] += line_item.quantity + totals[:items_total] += line_item.amount + + sales_tax = tax_included_in line_item + + if sales_tax > 0 + totals[:taxable_total] += line_item.amount + totals[:sales_tax] += sales_tax + end + end + + totals.each_pair do |k, v| + totals[k] = totals[k].round(2) + end + + totals + end + + def shipping_cost_for(order) + shipping_cost = order.adjustments.find_by_label("Shipping").andand.amount + shipping_cost = shipping_cost.nil? ? 0.0 : shipping_cost + end + + def shipping_tax_on(shipping_cost) + if shipment_inc_vat && shipping_cost.present? + (shipping_cost * shipping_tax_rate / (1 + shipping_tax_rate)).round(2) + else + 0 + end + end + + def tax_included_in(line_item) + line_item.adjustments.included_tax.sum &:amount + end + + def shipment_inc_vat + Spree::Config.shipment_inc_vat + end + + def shipping_tax_rate + Spree::Config.shipping_tax_rate + end + end +end diff --git a/spec/features/admin/authentication_spec.rb b/spec/features/admin/authentication_spec.rb index beb8c31c2b..12e9795ad2 100644 --- a/spec/features/admin/authentication_spec.rb +++ b/spec/features/admin/authentication_spec.rb @@ -2,6 +2,9 @@ require 'spec_helper' feature "Authentication", js: true do include UIComponentHelper + include AuthenticationWorkflow + include WebHelper + let(:user) { create(:user, password: "password", password_confirmation: "password") } scenario "logging into admin redirects home, then back to admin" do @@ -16,4 +19,10 @@ feature "Authentication", js: true do page.should have_content "Dashboard" current_path.should == spree.admin_path end + + scenario "viewing my account" do + login_to_admin_section + click_link "Account" + current_path.should == spree.account_path + end end diff --git a/spec/features/admin/enterprise_fees_spec.rb b/spec/features/admin/enterprise_fees_spec.rb index 44f26deddd..36fe1b76ac 100644 --- a/spec/features/admin/enterprise_fees_spec.rb +++ b/spec/features/admin/enterprise_fees_spec.rb @@ -6,9 +6,12 @@ feature %q{ }, js: true do include AuthenticationWorkflow include WebHelper - + + let!(:tax_category_gst) { create(:tax_category, name: 'GST') } + let!(:tax_category_gst_exempt) { create(:tax_category, name: 'GST exempt') } + scenario "listing enterprise fees" do - fee = create(:enterprise_fee, name: '$0.50 / kg', fee_type: 'packing') + fee = create(:enterprise_fee, name: '$0.50 / kg', fee_type: 'packing', tax_category: tax_category_gst) amount = fee.calculator.preferred_amount login_to_admin_section @@ -18,6 +21,7 @@ feature %q{ page.should have_selector "#enterprise_fee_set_collection_attributes_0_enterprise_id" page.should have_selector "option[selected]", text: 'Packing' page.should have_selector "input[value='$0.50 / kg']" + page.should have_selector "option[selected]", text: 'GST' page.should have_selector "option[selected]", text: 'Flat Rate (per item)' page.should have_selector "input[value='#{amount}']" end @@ -35,6 +39,7 @@ feature %q{ select 'Feedme', from: 'enterprise_fee_set_collection_attributes_0_enterprise_id' select 'Admin', from: 'enterprise_fee_set_collection_attributes_0_fee_type' fill_in 'enterprise_fee_set_collection_attributes_0_name', with: 'Hello!' + select 'GST', from: 'enterprise_fee_set_collection_attributes_0_tax_category_id' select 'Flat Percent', from: 'enterprise_fee_set_collection_attributes_0_calculator_type' click_button 'Update' @@ -64,6 +69,7 @@ feature %q{ select 'Foo', from: 'enterprise_fee_set_collection_attributes_0_enterprise_id' select 'Admin', from: 'enterprise_fee_set_collection_attributes_0_fee_type' fill_in 'enterprise_fee_set_collection_attributes_0_name', with: 'Greetings!' + select 'GST exempt', from: 'enterprise_fee_set_collection_attributes_0_tax_category_id' select 'Flat Percent', from: 'enterprise_fee_set_collection_attributes_0_calculator_type' click_button 'Update' @@ -71,6 +77,7 @@ feature %q{ page.should have_selector "option[selected]", text: 'Foo' page.should have_selector "option[selected]", text: 'Admin' page.should have_selector "input[value='Greetings!']" + page.should have_selector "option[selected]", text: 'GST exempt' page.should have_selector "option[selected]", text: 'Flat Percent' end @@ -137,6 +144,7 @@ feature %q{ select distributor1.name, :from => 'enterprise_fee_set_collection_attributes_0_enterprise_id' fill_in 'enterprise_fee_set_collection_attributes_0_name', :with => 'foo' + select 'GST', from: 'enterprise_fee_set_collection_attributes_0_tax_category_id' select 'Flat Percent', :from => 'enterprise_fee_set_collection_attributes_0_calculator_type' click_button 'Update' diff --git a/spec/features/admin/reports_spec.rb b/spec/features/admin/reports_spec.rb index 0524a6769e..2ad4e34f84 100644 --- a/spec/features/admin/reports_spec.rb +++ b/spec/features/admin/reports_spec.rb @@ -98,6 +98,63 @@ feature %q{ page.should have_content 'Payment State' end + + describe "Sales tax report" do + let(:user1) do + create_enterprise_user(enterprises: [create(:distributor_enterprise)]) + end + let(:user2) do + create_enterprise_user(enterprises: [create(:distributor_enterprise)]) + end + let(:tax_category1) { create(:tax_category) } + let(:tax_category2) { create(:tax_category) } + let!(:tax_rate1) { create(:tax_rate, amount: 0.0, calculator: Spree::Calculator::DefaultTax.new, tax_category: tax_category1) } + let!(:tax_rate2) { create(:tax_rate, amount: 0.2, calculator: Spree::Calculator::DefaultTax.new, tax_category: tax_category2) } + + let(:product1) { create(:product, price: 12.54, tax_category: tax_category1) } + let(:product2) { create(:product, price: 500.15, tax_category: tax_category2) } + + let(:shipping_method) { create(:shipping_method, name: "Shipping", description: "Expensive", calculator: Spree::Calculator::FlatRate.new(preferred_amount: 100.55)) } + let(:order1) { create(:order, distributor: user1.enterprises.first, shipping_method: shipping_method, bill_address: create(:address)) } + let!(:line_item1) { create(:line_item, variant: product1.master, price: 12.54, quantity: 1, order: order1) } + let!(:line_item2) { create(:line_item, variant: product2.master, price: 500.15, quantity: 3, order: order1) } + + let!(:adj_shipping) { create(:adjustment, adjustable: order1, label: "Shipping", amount: 100.55) } + let!(:adj_li2_tax) { create(:adjustment, adjustable: line_item2, source: line_item2, originator: tax_rate2, label: "RandomTax", amount: 123.00) } + + before do + Spree::Config.shipment_inc_vat = true + Spree::Config.shipping_tax_rate = 0.2 + order1.finalize! + + login_to_admin_as user1 + click_link "Reports" + click_link "Sales Tax" + end + + it "reports" do + # Then it should give me access only to managed enterprises + page.should have_select 'q_distributor_id_eq', with_options: [user1.enterprises.first.name] + page.should_not have_select 'q_distributor_id_eq', with_options: [user2.enterprises.first.name] + + # When I filter to just one distributor + select user1.enterprises.first.name, from: 'q_distributor_id_eq' + click_button 'Search' + + # Then I should see the relevant order + page.should have_content "#{order1.number}" + + # And the totals and sales tax should be correct + page.should have_content "1512.99" # items total + page.should have_content "1500.45" # taxable items total + page.should have_content "123.0" # sales tax (from adj_li2_tax, not calculated on the fly) + page.should_not have_content "250.08" # the number that would have been calculated on the fly + + # And the shipping cost and tax should be correct + page.should have_content "100.55" # shipping cost + page.should have_content "16.76" # shipping tax # TODO: do not calculate on the fly + end + end describe "orders & fulfilment reports" do it "loads the report page" do diff --git a/spec/lib/open_food_network/enterprise_fee_applicator_spec.rb b/spec/lib/open_food_network/enterprise_fee_applicator_spec.rb index 9ad1d222b3..6703f844ab 100644 --- a/spec/lib/open_food_network/enterprise_fee_applicator_spec.rb +++ b/spec/lib/open_food_network/enterprise_fee_applicator_spec.rb @@ -65,4 +65,33 @@ module OpenFoodNetwork efa.send(:order_adjustment_label).should == "Whole order - packing fee by distributor Ballantyne" end end + + describe "ensuring that tax rate is marked as tax included_in_price" do + let(:efa) { EnterpriseFeeApplicator.new nil, nil, nil } + let(:tax_rate) { create(:tax_rate, included_in_price: false, calculator: Spree::Calculator::DefaultTax.new) } + + it "sets included_in_price to true" do + efa.send(:with_tax_included_in_price, tax_rate) do + tax_rate.included_in_price.should be_true + end + end + + it "sets the included_in_price value accessible to the calculator to true" do + efa.send(:with_tax_included_in_price, tax_rate) do + tax_rate.calculator.calculable.included_in_price.should be_true + end + end + + it "passes through the return value of the block" do + efa.send(:with_tax_included_in_price, tax_rate) do + 'asdf' + end.should == 'asdf' + end + + it "restores both values to their original afterwards" do + efa.send(:with_tax_included_in_price, tax_rate) {} + tax_rate.included_in_price.should be_false + tax_rate.calculator.calculable.included_in_price.should be_false + end + end end diff --git a/spec/lib/open_food_network/sales_tax_report_spec.rb b/spec/lib/open_food_network/sales_tax_report_spec.rb new file mode 100644 index 0000000000..043eac6ae7 --- /dev/null +++ b/spec/lib/open_food_network/sales_tax_report_spec.rb @@ -0,0 +1,79 @@ +require 'open_food_network/sales_tax_report' + +module OpenFoodNetwork + describe SalesTaxReport do + let(:report) { SalesTaxReport.new(nil) } + + describe "calculating totals for line items" do + let(:li1) { double(:line_item, quantity: 1, amount: 12) } + let(:li2) { double(:line_item, quantity: 2, amount: 24) } + let(:totals) { report.send(:totals_of, [li1, li2]) } + + before do + report.stub(:tax_included_in).and_return(2, 4) + end + + it "calculates total quantity" do + totals[:items].should == 3 + end + + it "calculates total price" do + totals[:items_total].should == 36 + end + + context "when floating point math would result in fractional cents" do + let(:li1) { double(:line_item, quantity: 1, amount: 0.11) } + let(:li2) { double(:line_item, quantity: 2, amount: 0.12) } + + it "rounds to the nearest cent" do + totals[:items_total].should == 0.23 + end + end + + it "calculates the taxable total price" do + totals[:taxable_total].should == 36 + end + + it "calculates sales tax" do + totals[:sales_tax].should == 6 + end + + context "when there is no tax on a line item" do + before do + report.stub(:tax_included_in) { 0 } + end + + it "does not appear in taxable total" do + totals[:taxable_total].should == 0 + end + + it "still appears on items total" do + totals[:items_total].should == 36 + end + + it "does not register sales tax" do + totals[:sales_tax].should == 0 + end + end + end + + describe "calculating the shipping tax on a shipping cost" do + it "returns zero when shipping does not include VAT" do + report.stub(:shipment_inc_vat) { false } + report.send(:shipping_tax_on, 12).should == 0 + end + + it "returns zero when no shipping cost is passed" do + report.stub(:shipment_inc_vat) { true } + report.send(:shipping_tax_on, nil).should == 0 + end + + + it "returns the tax included in the price otherwise" do + report.stub(:shipment_inc_vat) { true } + report.stub(:shipping_tax_rate) { 0.2 } + report.send(:shipping_tax_on, 12).should == 2 + end + end + end +end diff --git a/spec/models/spree/ability_spec.rb b/spec/models/spree/ability_spec.rb index 30fbbd8ab6..19006cbcd1 100644 --- a/spec/models/spree/ability_spec.rb +++ b/spec/models/spree/ability_spec.rb @@ -350,7 +350,7 @@ module Spree end it "should be able to read some reports" do - should have_ability([:admin, :index, :customers, :group_buys, :bulk_coop, :payments, :orders_and_distributors, :orders_and_fulfillment, :products_and_inventory], for: :report) + should have_ability([:admin, :index, :customers, :sales_tax, :group_buys, :bulk_coop, :payments, :orders_and_distributors, :orders_and_fulfillment, :products_and_inventory], for: :report) end it "should not be able to read other reports" do diff --git a/spec/models/spree/adjustment_spec.rb b/spec/models/spree/adjustment_spec.rb index 60dfd631ce..35746b28fd 100644 --- a/spec/models/spree/adjustment_spec.rb +++ b/spec/models/spree/adjustment_spec.rb @@ -4,5 +4,150 @@ module Spree adjustment = create(:adjustment, metadata: create(:adjustment_metadata)) adjustment.metadata.should be end + + describe "recording included tax" do + describe "TaxRate adjustments" do + let!(:zone) { create(:zone, default_tax: true) } + let!(:zone_member) { ZoneMember.create!(zone: zone, zoneable: Country.find_by_name('Australia')) } + let!(:order) { create(:order) } + 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(:adjustment) { line_item.adjustments(:reload).first } + + before do + order.reload + tax_rate.adjust(order) + end + + it "has 100% tax included" do + adjustment.amount.should be > 0 + adjustment.included_tax.should == adjustment.amount + end + end + + describe "Shipment adjustments" do + let!(:order) { create(:order, shipping_method: shipping_method) } + let!(:line_item) { create(:line_item, order: order) } + let(:shipping_method) { create(:shipping_method, calculator: Calculator::FlatRate.new(preferred_amount: 50.0)) } + let(:adjustment) { order.adjustments(:reload).shipping.first } + + it "has a shipping charge of $50" do + order.create_shipment! + adjustment.amount.should == 50 + end + + describe "when tax on shipping is disabled" do + it "records 0% tax on shipment adjustments" do + Config.shipment_inc_vat = false + Config.shipping_tax_rate = 0 + order.create_shipment! + + adjustment.included_tax.should == 0 + end + + it "records 0% tax on shipments when a rate is set but shipment_inc_vat is false" do + Config.shipment_inc_vat = false + Config.shipping_tax_rate = 0.25 + order.create_shipment! + + adjustment.included_tax.should == 0 + end + end + + describe "when tax on shipping is enabled" do + before do + Config.shipment_inc_vat = true + Config.shipping_tax_rate = 0.25 + order.create_shipment! + end + + it "takes the shipment adjustment tax included from the system setting" do + # Finding the tax included in an amount that's already inclusive of tax: + # total - ( total / (1 + rate) ) + # 50 - ( 50 / (1 + 0.25) ) + # = 10 + adjustment.included_tax.should == 10.00 + end + + it "records 0% tax on shipments when shipping_tax_rate is not set" do + Config.shipment_inc_vat = true + Config.shipping_tax_rate = nil + order.create_shipment! + + adjustment.included_tax.should == 0 + end + end + end + + describe "EnterpriseFee adjustments" do + let!(:zone) { create(:zone, default_tax: true) } + let!(:zone_member) { ZoneMember.create!(zone: zone, zoneable: Country.find_by_name('Australia')) } + let(:tax_rate) { create(:tax_rate, included_in_price: true, calculator: Calculator::DefaultTax.new, zone: zone, amount: 0.1) } + let(:tax_category) { create(:tax_category, tax_rates: [tax_rate]) } + + let(:coordinator) { create(:distributor_enterprise) } + let(:variant) { create(:variant) } + let(:order_cycle) { create(:simple_order_cycle, coordinator: coordinator, coordinator_fees: [enterprise_fee], distributors: [coordinator], variants: [variant]) } + let!(:order) { create(:order, order_cycle: order_cycle, distributor: coordinator) } + let!(:line_item) { create(:line_item, order: order, variant: variant) } + let(:adjustment) { order.adjustments(:reload).enterprise_fee.first } + + before do + order.reload.update_distribution_charge! + end + + context "when enterprise fees are taxed per-order" do + let(:enterprise_fee) { create(:enterprise_fee, enterprise: coordinator, tax_category: tax_category, calculator: Calculator::FlatRate.new(preferred_amount: 50.0)) } + + it "records the tax on the enterprise fee adjustments" do + # The fee is $50, tax is 10%, and the fee is inclusive of tax + # Therefore, the included tax should be 0.1/1.1 * 50 = $4.55 + + adjustment.included_tax.should == 4.55 + end + + describe "when the tax rate does not include the tax in the price" do + before do + tax_rate.update_attribute :included_in_price, false + order.update_distribution_charge! + end + + it "treats it as inclusive anyway" do + adjustment.included_tax.should == 4.55 + end + end + + describe "when enterprise fees have no tax" do + before do + enterprise_fee.tax_category = nil + enterprise_fee.save! + order.update_distribution_charge! + end + + it "records no tax as charged" do + adjustment.included_tax.should == 0 + end + end + end + + + context "when enterprise fees are taxed per-item" do + let(:enterprise_fee) { create(:enterprise_fee, enterprise: coordinator, tax_category: tax_category, calculator: Calculator::PerItem.new(preferred_amount: 50.0)) } + + it "records the tax on the enterprise fee adjustments" do + adjustment.included_tax.should == 4.55 + end + end + end + + describe "setting the included tax by tax rate" do + let(:adjustment) { Adjustment.new label: 'foo', amount: 50 } + + it "sets it, rounding to two decimal places" do + adjustment.set_included_tax! 0.25 + adjustment.included_tax.should == 10.00 + end + end + end end end