├── init.rb ├── Gemfile ├── Rakefile ├── spec ├── spec_helper.rb └── vat_calculator_spec.rb ├── lib ├── vat_calculator │ └── railtie.rb └── vat_calculator.rb ├── vat_calculator.gemspec ├── MIT-LICENSE ├── Gemfile.lock └── README.md /init.rb: -------------------------------------------------------------------------------- 1 | require 'vat_calculator' -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gemspec 4 | 5 | gem 'rake' 6 | 7 | group :test do 8 | gem 'rspec' 9 | gem 'mocha', :git => 'git://github.com/floehopper/mocha.git' 10 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rdoc/task' 3 | 4 | Bundler::GemHelper.install_tasks 5 | 6 | require 'rspec/core/rake_task' 7 | 8 | desc 'Run specs' 9 | task :spec do 10 | RSpec::Core::RakeTask.new(:spec) do |t| 11 | t.rspec_opts = %w{--colour --format progress} 12 | t.pattern = 'spec/*_spec.rb' 13 | end 14 | end 15 | 16 | task :default => [ :spec ] -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) 3 | 4 | require 'rubygems' 5 | require 'bundler' 6 | 7 | Bundler.setup 8 | Bundler.require(:test) 9 | 10 | require 'mocha' 11 | require 'rspec' 12 | require 'vat_calculator' 13 | 14 | RSpec.configure do |config| 15 | config.mock_with :mocha 16 | end -------------------------------------------------------------------------------- /lib/vat_calculator/railtie.rb: -------------------------------------------------------------------------------- 1 | module VatCalculator 2 | 3 | class Railtie < Rails::Railtie 4 | 5 | config.vat_calculator_base_country = DEFAULT_BASE_COUNTRY 6 | 7 | initializer 'vat_calculator.initialize' do |app| 8 | VatCalculator.base_country = app.config.vat_calculator_base_country.upcase 9 | 10 | if rule = VatCalculator::VAT_RATES[VatCalculator.base_country] 11 | VatCalculator.current_rate_rule = VatCalculator.formalize_rate_rule(rule) 12 | end 13 | end 14 | end 15 | 16 | end -------------------------------------------------------------------------------- /vat_calculator.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "vat_calculator" 3 | s.summary = "Helper to calculate the VAT rate" 4 | s.description = "Helper to calculate the VAT rate" 5 | s.homepage = "http://github.com/did/vat_calculator" 6 | 7 | s.version = "1.2.2" 8 | s.date = "2011-05-12" 9 | 10 | s.authors = ["Didier Lafforgue"] 11 | s.email = "didier@nocoffee.fr" 12 | 13 | s.require_paths = ["lib"] 14 | s.files = Dir["lib/**/*"] + Dir["spec/**/*"] + ["README.md", "Rakefile"] 15 | 16 | s.has_rdoc = false 17 | 18 | s.rubygems_version = "1.3.4" 19 | s.required_rubygems_version = Gem::Requirement.new(">= 1.3.6") 20 | 21 | s.add_dependency 'savon', '~> 1.2.0' 22 | s.add_dependency 'activesupport' 23 | s.add_dependency 'vat_validator' 24 | end -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Didier Laffogue 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: git://github.com/floehopper/mocha.git 3 | revision: 3a06123daaf5d0fbc6cf93342aa173e94d41fb85 4 | specs: 5 | mocha (0.11.3) 6 | metaclass (~> 0.0.1) 7 | 8 | PATH 9 | remote: . 10 | specs: 11 | vat_calculator (1.2.2) 12 | activesupport 13 | savon (~> 1.2.0) 14 | vat_validator 15 | 16 | GEM 17 | remote: http://rubygems.org/ 18 | specs: 19 | activemodel (3.2.12) 20 | activesupport (= 3.2.12) 21 | builder (~> 3.0.0) 22 | activesupport (3.2.12) 23 | i18n (~> 0.6) 24 | multi_json (~> 1.0) 25 | akami (1.2.0) 26 | gyoku (>= 0.4.0) 27 | nokogiri (>= 1.4.0) 28 | builder (3.0.4) 29 | diff-lcs (1.2.1) 30 | gyoku (0.4.6) 31 | builder (>= 2.1.2) 32 | httpi (1.1.1) 33 | rack 34 | i18n (0.6.2) 35 | metaclass (0.0.1) 36 | multi_json (1.6.1) 37 | nokogiri (1.5.6) 38 | nori (1.1.4) 39 | rack (1.5.2) 40 | rake (10.0.3) 41 | rspec (2.13.0) 42 | rspec-core (~> 2.13.0) 43 | rspec-expectations (~> 2.13.0) 44 | rspec-mocks (~> 2.13.0) 45 | rspec-core (2.13.0) 46 | rspec-expectations (2.13.0) 47 | diff-lcs (>= 1.1.3, < 2.0) 48 | rspec-mocks (2.13.0) 49 | savon (1.2.0) 50 | akami (~> 1.2.0) 51 | builder (>= 2.1.2) 52 | gyoku (~> 0.4.5) 53 | httpi (~> 1.1.0) 54 | nokogiri (>= 1.4.0) 55 | nori (~> 1.1.0) 56 | wasabi (~> 2.5.0) 57 | vat_validator (1.2) 58 | activemodel 59 | savon (>= 0.9.7) 60 | wasabi (2.5.1) 61 | httpi (~> 1.0) 62 | nokogiri (>= 1.4.0) 63 | 64 | PLATFORMS 65 | ruby 66 | 67 | DEPENDENCIES 68 | mocha! 69 | rake 70 | rspec 71 | vat_calculator! 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Use this plugin to calculate the VAT rate depending on the country of the buyer. 4 | 5 | # Configuration 6 | 7 | First set the country of the seller in your config/application.rb file 8 | 9 | config.vat_calculator_base_country = 'FR' # or 'BE', etc.... 10 | 11 | Note: by default, the value of the vat_calculator_base_country option is set to 'FR' 12 | 13 | # Basic usage 14 | 15 | Let's review the different cases for a seller in France: 16 | 17 | 1. the buyer is outside Europe 18 | 19 | VatCalculator.get('US') # returns 0.0 20 | 21 | 2. the buyer is in France 22 | 23 | VatCalculator.get('FR') # returns 19.6 24 | 25 | 3. the buyer is in Europe without a vat number 26 | 27 | VatCalculator.get('BE') # returns 19.6 28 | 29 | 4. the buyer is in Europe with an almost correct vat number 30 | 31 | VatCalculator.get('BE', { vat_number: 'a_valid_vat_number' }) # returns 0.0 32 | 33 | 5. the buyer is in Europe with an almost correct vat number 34 | 35 | VatCalculator.get('BE', { vat_number: 'bla bla', validation: :simple }) # returns 19.6 36 | 37 | 6. the buyer is in Europe with an almost correct vat number 38 | 39 | VatCalculator.get('BE', { vat_number: 'BE00000000000', validation: :full }) # returns 0.0 40 | 41 | 7. the buyer is in Martinique, Guadeloupe or la Réunion 42 | 43 | VatCalculator.get('MQ') # returns 8.5 44 | 45 | 8. the buyer is in French Guyana 46 | 47 | VatCalculator.get('GF') # returns 0.0 48 | 49 | # Installation 50 | 51 | In your project's Gemfile : 52 | 53 | gem 'vat_calculator', git: 'git://github.com/did/vat_calculator.git' 54 | 55 | # Tests 56 | 57 | If you want to run the specs : 58 | 59 | bundle exec rake spec 60 | 61 | # Credits 62 | 63 | This plugin in released under MIT license by Didier Lafforgue (see MIT-LICENSE 64 | file). 65 | 66 | (c) http://www.nocoffee.fr -------------------------------------------------------------------------------- /spec/vat_calculator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Processing tax rate' do 4 | 5 | it 'should raise an exception of no country is provided' do 6 | lambda { 7 | VatCalculator.get(nil) 8 | }.should raise_exception(VatCalculator::NoCountryException) 9 | end 10 | 11 | it 'should raise an exception of the country of the seller is not recorded' do 12 | lambda { 13 | VatCalculator.get('FR', { base_country_code: 'DE' }) 14 | }.should raise_exception(VatCalculator::NoRuleFoundException) 15 | end 16 | 17 | context 'Seller in France' do 18 | 19 | it 'should apply tax since the customer is in France' do 20 | VatCalculator.get('FR').should == 19.6 21 | end 22 | 23 | it 'should apply tax if the buyer is in Europe BUT didn\'t give his vta number in CEE' do 24 | VatCalculator.get('IT').should == 19.6 25 | end 26 | 27 | it 'should not apply tax if the buyer is in Europe AND gave his vta number in CEE' do 28 | VatCalculator.get('IT', { vat_number: 'valid_number' }).should == 0.0 29 | end 30 | 31 | # Note (Did): see the vat_calculator.rb file for more explanations 32 | # %w(GP MQ RE).each do |code| 33 | # it "should apply a different tax rate if the buyer is in the country with the '#{code}' code" do 34 | # VatCalculator.get(code).should == 8.5 35 | # end 36 | # end 37 | 38 | it 'should not apply tax if the buyer lives in French Guyana' do 39 | VatCalculator.get('GF').should == 0.0 40 | end 41 | 42 | it 'should not apply tax if the buyer lives is in the United States' do 43 | VatCalculator.get('US').should == 0.0 44 | end 45 | 46 | end 47 | 48 | context 'Validating the VAT number' do 49 | 50 | it 'refuses a wrong VAT number' do 51 | VatCalculator.get('IT', { vat_number: 'invalid_number', validation: :simple }).should == 19.6 52 | end 53 | 54 | it 'accepts a wrong VAT number which looks like almost valid' do 55 | VatCalculator.get('IT', { vat_number: 'IT12345678923', validation: :simple }).should == 0.0 56 | end 57 | 58 | it 'refuses a VAT number by calling an external service' do 59 | VatCalculator.get('IT', { vat_number: 'IT12345678923', validation: :full }).should == 19.6 60 | end 61 | 62 | it 'accepts a VAT number by calling an external service' do 63 | VatCalculator.get('SE', { vat_number: 'SE556866920301', validation: :full }).should == 0.0 64 | end 65 | 66 | end 67 | 68 | end -------------------------------------------------------------------------------- /lib/vat_calculator.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | require 'vat_validator' 3 | require 'active_support' 4 | require 'active_support/core_ext/object/blank' 5 | require 'active_support/core_ext/module/attribute_accessors' 6 | 7 | module VatCalculator 8 | 9 | DEFAULT_BASE_COUNTRY = 'FR' 10 | 11 | mattr_accessor :base_country 12 | 13 | mattr_accessor :current_rate_rule 14 | 15 | # EUROPEAN_COUNTRIES = %w(DE AT BE BG CY DK ES EE FI FR EL HU IE IT LV LT LU MT NL PL GB RO SK SI SE CZ) # @deprecated 16 | 17 | VAT_RATES = { 18 | 'FR' => { 19 | :rate => 19.6 20 | # FIXME (Did): no exceptions an accouting person told me (to be double checked) 21 | # :exceptions => { 22 | # 'GF' => 0.0, # French Guyana 23 | # 'GP' => 8.5, # Guadeloupe 24 | # 'MQ' => 8.5, # Martinique 25 | # 'RE' => 8.5 # Réunion 26 | # } 27 | }, 28 | 29 | 'GP' => 8.5, 30 | 'MQ' => 8.5, 31 | 'RE' => 8.5, 32 | 33 | 'BE' => 21 34 | } 35 | 36 | def self.formalize_rate_rule(data) 37 | unless data.is_a?(Hash) 38 | data = { :rate => data.to_f } 39 | end 40 | 41 | OpenStruct.new({ 42 | :exceptions => {}, 43 | :rate => 0.0, 44 | :no_rate => 0.0 45 | }.merge(data)) 46 | end 47 | 48 | class NoCountryException < Exception; end 49 | 50 | class NoRuleFoundException < Exception; end 51 | 52 | # Method arguments: 53 | # country_code the country of the buyer [required] 54 | # options see below 55 | 56 | # Possible options are: 57 | # :base_country_code the country of the seller, if not given, then take the one by default [optional] 58 | # :vat_number in case the buyer left his vat number. Validation should have been done before [optional] 59 | # :validation :none, :simple (just check the format of the vat number), :full (format + existence of the vat number). By default, :none 60 | # 61 | def self.get(country_code, options = {}) 62 | raise NoCountryException if country_code.nil? 63 | 64 | country_code.upcase! 65 | 66 | base_country_code = self.base_country 67 | 68 | if options[:base_country_code] && options[:base_country_code] != self.base_country # different country code for the seller ? 69 | if rule_data = VAT_RATES[options[:base_country_code]] 70 | base_country_code = options[:base_country_code] 71 | rule = self.formalize_rate_rule(rule_data) 72 | end 73 | else 74 | rule = self.current_rate_rule 75 | end 76 | 77 | raise NoRuleFoundException if rule.nil? 78 | 79 | # same country for both the buyer and the seller, no need to go further 80 | return rule.rate if base_country_code == country_code 81 | 82 | if self.is_an_european_country?(country_code) 83 | # case when the country of the buyer is in Europe, check if he has a vat_number 84 | if options[:vat_number].present? && self.validate_vat_number?(options[:vat_number], options[:validation]) 85 | rule.no_rate 86 | else 87 | rule.rate 88 | end 89 | elsif rate = rule.exceptions[country_code] # exceptions (France for instance has many exceptions for the DOM-TOM territories) 90 | rate 91 | else 92 | rule.no_rate 93 | end 94 | end 95 | 96 | def self.is_an_european_country?(country_code) 97 | VatValidator::VAT_PATTERNS.keys.include?(country_code.upcase) 98 | end 99 | 100 | def self.validate_vat_number?(number, level = :none) 101 | case level 102 | when :simple then self.vat_format_valid?(number) 103 | when :full then self.vat_format_valid?(number) && self.vat_number_existence?(number) 104 | else 105 | true 106 | end 107 | end 108 | 109 | def self.vat_format_valid?(number) 110 | number =~ VatValidator::VAT_PATTERNS.values.detect { |p| number.to_s =~ p } 111 | end 112 | 113 | def self.vat_number_existence?(number) 114 | VatValidator::ViesChecker.check(number) 115 | end 116 | 117 | end 118 | 119 | if defined?(Rails) 120 | if Rails::VERSION::MAJOR >= 3 121 | require 'vat_calculator/railtie' 122 | else 123 | puts "[Warning] VatCalculator does not work with Rails < 3" 124 | end 125 | else 126 | VatCalculator.base_country ||= VatCalculator::DEFAULT_BASE_COUNTRY 127 | 128 | if rule_data = VatCalculator::VAT_RATES[VatCalculator.base_country] 129 | VatCalculator.current_rate_rule = VatCalculator.formalize_rate_rule(rule_data) 130 | end 131 | end --------------------------------------------------------------------------------