europe 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f6e189312895736dcfbb848edb51298272702f0a051133ce1fa10ff0ca332e9
4
- data.tar.gz: 772a816ac59d52a91428f94f998be5f42624a02c25698afea20c27789cb48071
3
+ metadata.gz: 988b8443805f27f2cb40846b2a2cef3cee76ac464f5c134f833c0ba63acdfce7
4
+ data.tar.gz: '087c687254747b8d653dad7f33de58f52474acfe5211b8a17a1e3be8d3d12fcb'
5
5
  SHA512:
6
- metadata.gz: 0d5827fce7a70c52def8b0869bca24fa7688363925ebc3e5c74f52f2f15a7b1d51277515f8a95418cb31ea1efdfc9b3357f5d00c7580a5a54a6920e22d83c815
7
- data.tar.gz: 7a5f8f4f1a5dac64cf8c449ab06a20751c73c8b86a0aa7b7eecc2f452b797435de303016886bc87bac6a8add7b5917e08da6441ded34a859ac90f6e897117d31
6
+ metadata.gz: 70a9e075c5b0c73d8472eb32729c6641a8a365a728be765f5b567516fface3113a2d6fd7751408af1c4aab67e6455a2b138bb92e99fbec41436ce4ae7fd5eb06
7
+ data.tar.gz: b03c256f4cb9e82129fd549dd57d38681df03877df510b6f224c25f9ddfa2125a44cb1279868d58f487d45971100461cf74722527437552d2db941e0921fc839
data/.rubocop.yml CHANGED
@@ -16,3 +16,18 @@ Style/RedundantRegexpCharacterClass:
16
16
 
17
17
  Style/CaseLikeIf:
18
18
  Enabled: false
19
+
20
+ Naming/PredicateMethod:
21
+ Enabled: false
22
+
23
+ Metrics/ModuleLength:
24
+ Max: 150
25
+ CountAsOne: ['hash', 'array']
26
+
27
+ Metrics/ClassLength:
28
+ Exclude:
29
+ - 'test/**/*'
30
+
31
+ Metrics/MethodLength:
32
+ Exclude:
33
+ - 'test/**/*'
data/CHANGELOG.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # Change Log
2
2
  All notable changes to this project will be documented in this file.
3
3
  This project adheres to [Semantic Versioning](http://semver.org/).
4
+ ## 0.0.28
5
+ - Added support for batch VAT checks
6
+ - Updated fallback rates for Estonia and Romania
7
+ - [Full Changelog](https://github.com/gem-shards/europe.rb/compare/v0.0.27...v0.0.28)
8
+ ## 0.0.27
9
+ - Updated VIES VAT check service to their REST API instead of their SOAP API
10
+ - Improved error handling for the VAT check service
11
+ - Changed the endpoint to check for VAT rates to increase it's stability
12
+ - [Full Changelog](https://github.com/gem-shards/europe.rb/compare/v0.0.26...v0.0.27)
4
13
  ## 0.0.26
5
14
  - Removed spaces by default for VAT number format validations, as per request from tim-vandecasteele
6
15
  - [Full Changelog](https://github.com/gem-shards/europe.rb/compare/v0.0.25...v0.0.26)
data/README.md CHANGED
@@ -7,6 +7,7 @@ This gem provides EU governmental data, extracted from various EU / EC websites.
7
7
  - [Installation](#installation)
8
8
  - [Usage](#usage)
9
9
  - [Validating VAT numbers](#validating-vat-numbers)
10
+ - [Batch VAT validation](#batch-vat-validation)
10
11
  - [Validate VAT number format](#validate-vat-number-format)
11
12
  - [Validate Postal code format](#validate-postal-code-format)
12
13
  - [Retrieving VAT rates for each EC/EU member](#retrieving-vat-rates-for-each-eceu-member)
@@ -55,6 +56,34 @@ Response
55
56
  :address => nil }
56
57
  ```
57
58
 
59
+ ### Batch VAT validation
60
+ Validate 3-100 VAT numbers in one request using the VIES batch API. The process is asynchronous: submit numbers, poll for status, then download the report.
61
+
62
+ **Step 1: Submit batch**
63
+ ```ruby
64
+ result = Europe::Vat::Batch.validate(['NL009291477B01', 'BE0123456789', 'DE123456789'])
65
+ # => { token: "a1b2c3d4-..." }
66
+ ```
67
+ Optionally pass your own VAT number as requester:
68
+ ```ruby
69
+ result = Europe::Vat::Batch.validate(vat_numbers, requester: 'NL009291477B01')
70
+ ```
71
+
72
+ **Step 2: Check status**
73
+ ```ruby
74
+ Europe::Vat::Batch.status(result[:token])
75
+ # => { token: "a1b2c3d4-...", filename: "batch.csv", status: :completed,
76
+ # percentage: 100, created_at: "2026-06-08...", completed_at: "2026-06-08..." }
77
+ ```
78
+
79
+ **Step 3: Download report**
80
+ ```ruby
81
+ report = Europe::Vat::Batch.result(result[:token])
82
+ # => { data: "...csv content...", content_type: "text/csv", filename: "report.csv" }
83
+ ```
84
+
85
+ All methods return error symbols on failure (`:timeout`, `:service_unavailable`, `:too_few_rows`, `:too_many_rows`, etc.).
86
+
58
87
  ### Validate VAT number format
59
88
  Call
60
89
  ```ruby
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'securerandom'
7
+
8
+ module Europe
9
+ module Vat
10
+ # Batch VAT validation via VIES REST API
11
+ module Batch
12
+ BATCH_API_URL = 'https://ec.europa.eu/taxation_customs/vies/rest-api/vat-validation'
13
+ BATCH_REPORT_URL = 'https://ec.europa.eu/taxation_customs/vies/rest-api/vat-validation-report'
14
+
15
+ BATCH_ERRORS = {
16
+ 'VOW-ERR-6000' => :invalid_file_extension,
17
+ 'VOW-ERR-6001' => :too_many_rows,
18
+ 'VOW-ERR-6002' => :too_few_rows,
19
+ 'VOW-ERR-6005' => :filter_violation,
20
+ 'VOW-ERR-6006' => :token_not_found,
21
+ 'VOW-ERR-6007' => :invalid_file_structure,
22
+ 'VOW-ERR-6008' => :invalid_file_structure,
23
+ 'VOW-ERR-6011' => :file_too_large
24
+ }.freeze
25
+
26
+ BATCH_MIN_ROWS = 3
27
+ BATCH_MAX_ROWS = 100
28
+
29
+ HEADERS = {
30
+ 'Content-Type' => 'application/json',
31
+ 'Accept' => 'application/json'
32
+ }.freeze
33
+
34
+ class << self
35
+ def validate(vat_numbers, requester: nil)
36
+ return :too_few_rows if vat_numbers.size < BATCH_MIN_ROWS
37
+ return :too_many_rows if vat_numbers.size > BATCH_MAX_ROWS
38
+
39
+ with_error_handling do
40
+ body = parse_json_response(upload(generate_csv(vat_numbers, requester)))
41
+ return body if body.is_a?(Symbol)
42
+
43
+ { token: body['token'] }
44
+ end
45
+ end
46
+
47
+ def status(token)
48
+ with_error_handling do
49
+ body = parse_json_response(http_get("#{BATCH_API_URL}/#{token}", HEADERS))
50
+ return body if body.is_a?(Symbol)
51
+
52
+ {
53
+ token: body['token'], filename: body['filename'],
54
+ status: body['status']&.downcase&.to_sym, percentage: body['percentage'],
55
+ created_at: body['creationDateTime'], completed_at: body['completionTime']
56
+ }
57
+ end
58
+ end
59
+
60
+ def result(token)
61
+ with_error_handling do
62
+ response = http_get("#{BATCH_REPORT_URL}/#{token}")
63
+ return handle_error(response) unless response.is_a?(Net::HTTPSuccess)
64
+
65
+ error = check_json_error(response)
66
+ return error if error
67
+
68
+ { data: response.body, content_type: response['Content-Type'], filename: extract_filename(response) }
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def with_error_handling
75
+ yield
76
+ rescue Net::OpenTimeout, Net::ReadTimeout
77
+ :timeout
78
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, OpenSSL::SSL::SSLError
79
+ :service_unavailable
80
+ rescue JSON::ParserError
81
+ :service_error
82
+ end
83
+
84
+ def http_get(url, headers = {})
85
+ uri = URI.parse(url)
86
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
87
+ http.request(Net::HTTP::Get.new(uri.request_uri, headers))
88
+ end
89
+ end
90
+
91
+ def parse_json_response(response)
92
+ return handle_error(response) unless response.is_a?(Net::HTTPSuccess)
93
+
94
+ body = JSON.parse(response.body)
95
+ return handle_error_body(body) if body['actionSucceed'] == false
96
+
97
+ body
98
+ end
99
+
100
+ def check_json_error(response)
101
+ return unless response['Content-Type']&.include?('application/json')
102
+
103
+ body = JSON.parse(response.body)
104
+ handle_error_body(body) if body['actionSucceed'] == false
105
+ end
106
+
107
+ def extract_filename(response)
108
+ disposition = response['Content-Disposition']
109
+ return unless disposition
110
+
111
+ disposition.match(/filename="?(.+?)"?(?:;|$)/)&.captures&.first
112
+ end
113
+
114
+ def handle_error(response)
115
+ body = JSON.parse(response.body)
116
+ handle_error_body(body)
117
+ rescue JSON::ParserError
118
+ :service_error
119
+ end
120
+
121
+ def handle_error_body(body)
122
+ error_code = body.dig('errorWrappers', 0, 'error')
123
+ BATCH_ERRORS[error_code] || Vat::VIES_ERRORS[error_code] || :service_error
124
+ end
125
+
126
+ def generate_csv(vat_numbers, requester)
127
+ requester_cc = requester ? requester[0..1] : ''
128
+ requester_vat = requester ? requester[2..-1] : ''
129
+ rows = ['"MS Code","VAT Number","Requester MS Code","Requester VAT Number"']
130
+ vat_numbers.each do |vat|
131
+ rows << "\"#{vat[0..1]}\",\"#{vat[2..-1]}\",\"#{requester_cc}\",\"#{requester_vat}\""
132
+ end
133
+ "#{rows.join("\n")}\n"
134
+ end
135
+
136
+ def upload(csv_content)
137
+ uri = URI.parse(BATCH_API_URL)
138
+ boundary = "EuropeGem#{SecureRandom.hex(16)}"
139
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
140
+ request = Net::HTTP::Post.new(uri.request_uri)
141
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
142
+ request['Accept'] = 'application/json'
143
+ request.body = build_multipart_body(csv_content, boundary)
144
+ http.request(request)
145
+ end
146
+ end
147
+
148
+ def build_multipart_body(csv_content, boundary)
149
+ "--#{boundary}\r\n" \
150
+ "Content-Disposition: form-data; name=\"fileToUpload\"; filename=\"batch.csv\"\r\n" \
151
+ "Content-Type: text/csv\r\n\r\n" \
152
+ "#{csv_content}" \
153
+ "\r\n--#{boundary}--\r\n"
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -1,19 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rexml/document'
3
+ require 'json'
4
4
 
5
5
  module Europe
6
6
  module Vat
7
7
  # Rates
8
8
  module Rates
9
9
  FALLBACK_RATES = {
10
- AT: 20.0, BE: 21.0, BG: 20.0, CY: 19.0, CZ: 21.0, DE: 19.0, DK: 25.0, EE: 22.0,
10
+ AT: 20.0, BE: 21.0, BG: 20.0, CY: 19.0, CZ: 21.0, DE: 19.0, DK: 25.0, EE: 24.0,
11
11
  EL: 24.0, ES: 21.0, FI: 25.5, FR: 20.0, HR: 25.0, HU: 27.0, IE: 23.0,
12
12
  IT: 22.0, LT: 21.0, LU: 17.0, LV: 21.0, MT: 18.0, NL: 21.0, PL: 23.0, PT: 23.0,
13
- RO: 19.0, SE: 25.0, SI: 22.0, SK: 23.0
13
+ RO: 21.0, SE: 25.0, SI: 22.0, SK: 23.0
14
14
  }.freeze
15
- RATES_URL = 'https://europa.eu/youreurope/business/taxation/vat' \
16
- '/vat-rules-rates/index_en.htm'
15
+ RATES_URL = 'https://raw.githubusercontent.com/benbucksch/eu-vat-rates' \
16
+ '/refs/heads/master/rates.json'
17
+
18
+ COUNTRY_CODE_MAP = { GR: :EL }.freeze
17
19
 
18
20
  def self.retrieve
19
21
  resp = fetch_rates
@@ -22,33 +24,16 @@ module Europe
22
24
  extract_rates(resp)
23
25
  end
24
26
 
25
- # rubocop:disable Metrics/MethodLength
26
27
  def self.extract_rates(resp)
28
+ data = JSON.parse(resp)
27
29
  rates = {}
28
-
29
- begin
30
- data = resp.scan(%r{\<tbody\>(.*)\<\/tbody\>}m).first.first.strip
31
- rescue NoMethodError
32
- return FALLBACK_RATES
33
- end
34
-
35
- xml = REXML::Document.new("<root>#{data}</root>")
36
- xml.first.elements.each('tr') do |result|
37
- next if result[3].nil?
38
-
39
- rates = filter_rate(result, rates || {})
30
+ data['rates'].each do |code, info|
31
+ key = COUNTRY_CODE_MAP[code.to_sym] || code.to_sym
32
+ rates[key] = info['standard_rate'].to_f
40
33
  end
41
34
  rates
42
- end
43
- # rubocop:enable Metrics/MethodLength
44
-
45
- def self.filter_rate(result, rates)
46
- return unless result[1].text.size == 2
47
-
48
- country = result[1].text
49
- rate = result[5].text
50
- rates[country.to_sym] = rate.to_f if country && rate
51
- rates
35
+ rescue JSON::ParserError
36
+ FALLBACK_RATES
52
37
  end
53
38
 
54
39
  def self.fetch_rates
@@ -2,79 +2,98 @@
2
2
 
3
3
  require 'europe/vat/rates'
4
4
  require 'europe/vat/format'
5
+ require 'europe/vat/batch'
5
6
  require 'uri'
6
7
  require 'net/http'
7
- require 'rexml/document'
8
+ require 'json'
8
9
  require 'date'
9
10
 
10
11
  # Europe Gem
11
12
  module Europe
12
13
  # VAT
13
14
  module Vat
14
- WSDL = 'http://ec.europa.eu/taxation_customs/vies/' \
15
- 'services/checkVatService'
15
+ API_URL = 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number'
16
16
  HEADERS = {
17
- 'Content-Type' => 'text/xml;charset=UTF-8',
18
- 'SOAPAction' => ''
17
+ 'Content-Type' => 'application/json',
18
+ 'Accept' => 'application/json'
19
19
  }.freeze
20
20
 
21
- BODY = <<-XML
22
- <soapenv:Envelope
23
- xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
24
- xmlns:urn="urn:ec.europa.eu:taxud:vies:services:checkVat:types">
25
- <soapenv:Header/>
26
- <soapenv:Body>
27
- <urn:checkVat>
28
- <urn:countryCode>{COUNTRY_CODE}</urn:countryCode>
29
- <urn:vatNumber>{NUMBER}</urn:vatNumber>
30
- </urn:checkVat>
31
- </soapenv:Body>
32
- </soapenv:Envelope>
33
- XML
21
+ VIES_ERRORS = {
22
+ 'INVALID_INPUT' => :invalid_input,
23
+ 'GLOBAL_MAX_CONCURRENT_REQ' => :rate_limited,
24
+ 'MS_MAX_CONCURRENT_REQ' => :rate_limited,
25
+ 'SERVICE_UNAVAILABLE' => :service_unavailable,
26
+ 'MS_UNAVAILABLE' => :ms_unavailable,
27
+ 'TIMEOUT' => :timeout
28
+ }.freeze
34
29
 
35
30
  def self.validate(number)
36
- return :failed if number.size < 4
31
+ return :invalid_input if number.size < 4
37
32
 
38
33
  response = send_request(number[0..1], number[2..-1])
39
- return :failed unless response.is_a? Net::HTTPSuccess
40
- return :failed if response.body.include?('soap:Fault')
41
- return :timeout if response.body.include?('TIMEOUT')
42
- return :timeout if response.body.include?('MS_MAX_CONCURRENT_REQ')
34
+ return handle_error_response(response) unless response.is_a?(Net::HTTPSuccess)
35
+
36
+ setup_response(response)
37
+ rescue Net::OpenTimeout, Net::ReadTimeout
38
+ :timeout
39
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, OpenSSL::SSL::SSLError
40
+ :service_unavailable
41
+ end
42
+
43
+ def self.validate_with_country_code(country_code, number)
44
+ return :invalid_input if number.size < 4
45
+
46
+ # Remove country code from number if it's present
47
+ number = number[2..-1] if number[0..1].upcase.to_s == country_code.to_s
48
+
49
+ response = send_request(country_code, number)
50
+ return handle_error_response(response) unless response.is_a?(Net::HTTPSuccess)
43
51
 
44
52
  setup_response(response)
45
- rescue Net::OpenTimeout
53
+ rescue Net::OpenTimeout, Net::ReadTimeout
46
54
  :timeout
47
- rescue Net::HTTPServerError
48
- :server_error
55
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, OpenSSL::SSL::SSLError
56
+ :service_unavailable
57
+ end
58
+
59
+ def self.batch_validate(vat_numbers, requester: nil)
60
+ Batch.validate(vat_numbers, requester: requester)
61
+ end
62
+
63
+ def self.batch_status(token)
64
+ Batch.status(token)
65
+ end
66
+
67
+ def self.batch_result(token)
68
+ Batch.result(token)
69
+ end
70
+
71
+ def self.handle_error_response(response)
72
+ body = JSON.parse(response.body)
73
+ error_code = body.dig('errorWrappers', 0, 'error')
74
+ VIES_ERRORS[error_code] || :service_error
75
+ rescue JSON::ParserError
76
+ :service_error
49
77
  end
50
78
 
51
79
  def self.setup_response(response)
52
- body = response_xml(response)
80
+ body = JSON.parse(response.body)
53
81
  {
54
- valid: extract_data(body, 3) == 'true',
55
- country_code: extract_data(body, 0),
56
- vat_number: extract_data(body, 1),
57
- request_date: convert_date(extract_data(body, 2)),
58
- name: extract_data(body, 4),
59
- address: extract_data(body, 5)
82
+ valid: body['valid'] == true,
83
+ country_code: body['countryCode'],
84
+ vat_number: body['vatNumber'],
85
+ request_date: convert_date(body['requestDate']),
86
+ name: body['name'],
87
+ address: body['address']
60
88
  }
61
89
  end
62
90
 
63
- def self.response_xml(response)
64
- xml = REXML::Document.new(response.body)
65
- xml.elements.first.elements[2].elements[1]
66
- end
67
-
68
91
  def self.convert_date(date)
69
92
  return unless date
70
93
 
71
94
  Date.parse(date)
72
95
  end
73
96
 
74
- def self.extract_data(body, position)
75
- body[position]&.text
76
- end
77
-
78
97
  def self.charge_vat?(origin_country, number)
79
98
  return false if number.nil? || number.empty?
80
99
 
@@ -83,18 +102,16 @@ module Europe
83
102
  end
84
103
 
85
104
  def self.send_request(country_code, number)
86
- uri = URI.parse(WSDL)
87
-
88
- body = BODY.dup.gsub('{COUNTRY_CODE}', country_code)
89
- body = body.gsub('{NUMBER}', number)
90
-
91
- # Create the HTTP objects
92
- http = Net::HTTP.new(uri.host, uri.port)
93
- request = Net::HTTP::Post.new(uri.request_uri, HEADERS)
94
- request.body = body
95
-
96
- # Send the request
97
- http.request(request)
105
+ uri = URI.parse(API_URL)
106
+
107
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
108
+ request = Net::HTTP::Post.new(uri.request_uri, HEADERS)
109
+ request.body = JSON.generate(
110
+ countryCode: country_code,
111
+ vatNumber: number
112
+ )
113
+ http.request(request)
114
+ end
98
115
  end
99
116
  end
100
117
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Europe version
4
4
  module Europe
5
- VERSION = '0.0.26'
5
+ VERSION = '0.0.28'
6
6
  end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ module Europe
6
+ module Vat
7
+ BATCH_SUCCESS_BODY = '{"token":"test-token-uuid"}'
8
+ BATCH_STRUCTURE_ERROR_BODY = '{"actionSucceed":false,"errorWrappers":' \
9
+ '[{"error":"VOW-ERR-6008","message":' \
10
+ '"Your file does not match the expected structure"}]}'
11
+ BATCH_TOKEN_NOT_FOUND_BODY = '{"actionSucceed":false,"errorWrappers":' \
12
+ '[{"error":"VOW-ERR-6006","message":' \
13
+ '"The token is not valid"}]}'
14
+ BATCH_STATUS_PROCESSING_BODY = '{"token":"test-token-uuid","filename":"batch.csv",' \
15
+ '"creationDateTime":"2026-06-08T09:47:13.720Z",' \
16
+ '"status":"PROCESSING","processingStartTime":' \
17
+ '"2026-06-08T09:47:13.852Z","percentage":66.67}'
18
+ BATCH_STATUS_COMPLETED_BODY = '{"token":"test-token-uuid","filename":"batch.csv",' \
19
+ '"creationDateTime":"2026-06-08T09:47:13.720Z",' \
20
+ '"status":"COMPLETED","processingStartTime":' \
21
+ '"2026-06-08T09:47:13.852Z","completionTime":' \
22
+ '"2026-06-08T09:47:49.937Z","percentage":100.0}'
23
+
24
+ class BatchValidationTest < Minitest::Test
25
+ VAT_NUMBERS = %w[NL009291477B01 DE115235681 FR40303265045].freeze
26
+
27
+ def setup
28
+ WebMock.enable!
29
+ end
30
+
31
+ def teardown
32
+ WebMock.disable!
33
+ end
34
+
35
+ def test_batch_validate_returns_token
36
+ stub_request(:post, Europe::Vat::Batch::BATCH_API_URL)
37
+ .to_return(status: 200, body: BATCH_SUCCESS_BODY,
38
+ headers: { 'Content-Type' => 'application/json' })
39
+
40
+ result = Europe::Vat::Batch.validate(VAT_NUMBERS)
41
+ assert_equal({ token: 'test-token-uuid' }, result)
42
+ end
43
+
44
+ def test_batch_validate_too_few_rows
45
+ assert_equal :too_few_rows, Europe::Vat::Batch.validate(%w[NL009291477B01 DE115235681])
46
+ end
47
+
48
+ def test_batch_validate_too_many_rows
49
+ numbers = (1..101).map { |i| "NL#{i.to_s.rjust(12, '0')}" }
50
+ assert_equal :too_many_rows, Europe::Vat::Batch.validate(numbers)
51
+ end
52
+
53
+ def test_batch_validate_with_requester
54
+ stub_request(:post, Europe::Vat::Batch::BATCH_API_URL)
55
+ .with { |req| req.body.include?('NL') && req.body.include?('009291477B01') }
56
+ .to_return(status: 200, body: BATCH_SUCCESS_BODY,
57
+ headers: { 'Content-Type' => 'application/json' })
58
+
59
+ result = Europe::Vat::Batch.validate(VAT_NUMBERS, requester: 'NL009291477B01')
60
+ assert_equal({ token: 'test-token-uuid' }, result)
61
+ end
62
+
63
+ def test_batch_validate_structure_error
64
+ stub_request(:post, Europe::Vat::Batch::BATCH_API_URL)
65
+ .to_return(status: 400, body: BATCH_STRUCTURE_ERROR_BODY,
66
+ headers: { 'Content-Type' => 'application/json' })
67
+
68
+ assert_equal :invalid_file_structure, Europe::Vat::Batch.validate(VAT_NUMBERS)
69
+ end
70
+
71
+ def test_batch_validate_timeout
72
+ stub_request(:post, Europe::Vat::Batch::BATCH_API_URL).to_timeout
73
+
74
+ assert_equal :timeout, Europe::Vat::Batch.validate(VAT_NUMBERS)
75
+ end
76
+
77
+ def test_batch_validate_connection_refused
78
+ stub_request(:post, Europe::Vat::Batch::BATCH_API_URL).to_raise(Errno::ECONNREFUSED)
79
+
80
+ assert_equal :service_unavailable, Europe::Vat::Batch.validate(VAT_NUMBERS)
81
+ end
82
+
83
+ def test_batch_status_processing
84
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_API_URL}/test-token-uuid")
85
+ .to_return(status: 200, body: BATCH_STATUS_PROCESSING_BODY,
86
+ headers: { 'Content-Type' => 'application/json' })
87
+
88
+ result = Europe::Vat::Batch.status('test-token-uuid')
89
+ assert_equal :processing, result[:status]
90
+ assert_equal 66.67, result[:percentage]
91
+ end
92
+
93
+ def test_batch_status_completed
94
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_API_URL}/test-token-uuid")
95
+ .to_return(status: 200, body: BATCH_STATUS_COMPLETED_BODY,
96
+ headers: { 'Content-Type' => 'application/json' })
97
+
98
+ result = Europe::Vat::Batch.status('test-token-uuid')
99
+ assert_equal :completed, result[:status]
100
+ assert_equal 100.0, result[:percentage]
101
+ assert_equal 'test-token-uuid', result[:token]
102
+ refute_nil result[:completed_at]
103
+ end
104
+
105
+ def test_batch_status_invalid_token
106
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_API_URL}/bad-token")
107
+ .to_return(status: 400, body: BATCH_TOKEN_NOT_FOUND_BODY,
108
+ headers: { 'Content-Type' => 'application/json' })
109
+
110
+ assert_equal :token_not_found, Europe::Vat::Batch.status('bad-token')
111
+ end
112
+
113
+ def test_batch_status_timeout
114
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_API_URL}/test-token-uuid").to_timeout
115
+
116
+ assert_equal :timeout, Europe::Vat::Batch.status('test-token-uuid')
117
+ end
118
+
119
+ def test_batch_result_returns_data
120
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_REPORT_URL}/test-token-uuid")
121
+ .to_return(
122
+ status: 200,
123
+ body: 'fake-xlsx-binary-data',
124
+ headers: {
125
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
126
+ 'Content-Disposition' => 'attachment; filename="report.xlsx"'
127
+ }
128
+ )
129
+
130
+ result = Europe::Vat::Batch.result('test-token-uuid')
131
+ assert_equal 'fake-xlsx-binary-data', result[:data]
132
+ assert_equal 'report.xlsx', result[:filename]
133
+ assert result[:content_type].include?('spreadsheetml')
134
+ end
135
+
136
+ def test_batch_result_invalid_token
137
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_REPORT_URL}/bad-token")
138
+ .to_return(status: 400, body: BATCH_TOKEN_NOT_FOUND_BODY,
139
+ headers: { 'Content-Type' => 'application/json' })
140
+
141
+ assert_equal :token_not_found, Europe::Vat::Batch.result('bad-token')
142
+ end
143
+
144
+ def test_batch_result_timeout
145
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_REPORT_URL}/test-token-uuid").to_timeout
146
+
147
+ assert_equal :timeout, Europe::Vat::Batch.result('test-token-uuid')
148
+ end
149
+
150
+ def test_generate_batch_csv_format
151
+ csv = Europe::Vat::Batch.send(:generate_csv, VAT_NUMBERS, nil)
152
+ lines = csv.strip.split("\n")
153
+
154
+ assert_equal 4, lines.size
155
+ assert_equal '"MS Code","VAT Number","Requester MS Code","Requester VAT Number"', lines[0]
156
+ assert_equal '"NL","009291477B01","",""', lines[1]
157
+ assert_equal '"DE","115235681","",""', lines[2]
158
+ assert_equal '"FR","40303265045","",""', lines[3]
159
+ end
160
+
161
+ def test_generate_batch_csv_with_requester
162
+ csv = Europe::Vat::Batch.send(:generate_csv, VAT_NUMBERS, 'NL009291477B01')
163
+ lines = csv.strip.split("\n")
164
+
165
+ assert_equal '"NL","009291477B01","NL","009291477B01"', lines[1]
166
+ end
167
+
168
+ # Delegation tests — ensure Europe::Vat.batch_* still works
169
+ def test_delegation_batch_validate
170
+ stub_request(:post, Europe::Vat::Batch::BATCH_API_URL)
171
+ .to_return(status: 200, body: BATCH_SUCCESS_BODY,
172
+ headers: { 'Content-Type' => 'application/json' })
173
+
174
+ result = Europe::Vat.batch_validate(VAT_NUMBERS)
175
+ assert_equal({ token: 'test-token-uuid' }, result)
176
+ end
177
+
178
+ def test_delegation_batch_status
179
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_API_URL}/test-token-uuid")
180
+ .to_return(status: 200, body: BATCH_STATUS_COMPLETED_BODY,
181
+ headers: { 'Content-Type' => 'application/json' })
182
+
183
+ result = Europe::Vat.batch_status('test-token-uuid')
184
+ assert_equal :completed, result[:status]
185
+ end
186
+
187
+ def test_delegation_batch_result
188
+ stub_request(:get, "#{Europe::Vat::Batch::BATCH_REPORT_URL}/test-token-uuid")
189
+ .to_return(
190
+ status: 200,
191
+ body: 'fake-xlsx-binary-data',
192
+ headers: {
193
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
194
+ 'Content-Disposition' => 'attachment; filename="report.xlsx"'
195
+ }
196
+ )
197
+
198
+ result = Europe::Vat.batch_result('test-token-uuid')
199
+ assert_equal 'fake-xlsx-binary-data', result[:data]
200
+ end
201
+ end
202
+ end
203
+ end
@@ -4,6 +4,25 @@ require 'test_helper'
4
4
 
5
5
  module Europe
6
6
  module Vat
7
+ SERVICE_UNAVAILABLE_BODY = '{"actionSucceed":false,"errorWrappers":' \
8
+ '[{"error":"SERVICE_UNAVAILABLE","message":' \
9
+ '"An error was encountered either at the national ' \
10
+ 'level or at the European Commission"}]}'
11
+ MS_UNAVAILABLE_BODY = '{"actionSucceed":false,"errorWrappers":' \
12
+ '[{"error":"MS_UNAVAILABLE","message":"The Member State service is unavailable"}]}'
13
+ GLOBAL_RATE_LIMITED_BODY = '{"actionSucceed":false,"errorWrappers":' \
14
+ '[{"error":"GLOBAL_MAX_CONCURRENT_REQ","message":' \
15
+ '"Your Request for VAT validation has not been processed; ' \
16
+ 'your max. number of concurrent requests has been reached"}]}'
17
+ MS_RATE_LIMITED_BODY = '{"actionSucceed":false,"errorWrappers":' \
18
+ '[{"error":"MS_MAX_CONCURRENT_REQ","message":' \
19
+ '"Your Request for VAT validation has not been processed; ' \
20
+ 'your max. number of concurrent requests for this Member State has been reached"}]}'
21
+ INVALID_INPUT_BODY = '{"actionSucceed":false,"errorWrappers":' \
22
+ '[{"error":"INVALID_INPUT","message":"The provided CountryCode is invalid"}]}'
23
+ VIES_TIMEOUT_BODY = '{"actionSucceed":false,"errorWrappers":' \
24
+ '[{"error":"TIMEOUT","message":"The Member State service could not be reached in time"}]}'
25
+
7
26
  # ValidationTest
8
27
  class ValidationTest < Minitest::Test
9
28
  include Benchmark
@@ -13,7 +32,7 @@ module Europe
13
32
  end
14
33
 
15
34
  def test_validation_of_short_vat_number
16
- assert_equal :failed, Europe::Vat.validate('6')
35
+ assert_equal :invalid_input, Europe::Vat.validate('6')
17
36
  end
18
37
 
19
38
  def test_validation_of_false_vat_number
@@ -29,23 +48,76 @@ module Europe
29
48
  # PostNL
30
49
  validate_correct_vat = Europe::Vat.validate('NL009291477B01')
31
50
  assert validate_correct_vat[:valid] \
32
- unless %i[timeout failed].include?(validate_correct_vat)
51
+ unless validate_correct_vat.is_a?(Symbol)
33
52
 
34
53
  # Volkswagen
35
54
  validate_correct_vat = Europe::Vat.validate('DE115235681')
36
55
  assert validate_correct_vat[:valid] \
37
- unless %i[timeout failed].include?(validate_correct_vat)
56
+ unless validate_correct_vat.is_a?(Symbol)
38
57
  end
39
58
 
40
- def test_failed_request_to_soap_service
59
+ def test_timeout
41
60
  WebMock.enable!
42
- stub_request(:any, 'http://ec.europa.eu/taxation_customs/vies/services/checkVatService').to_timeout
43
- Europe::Vat.validate('DE115235681')
61
+ stub_request(:post, Europe::Vat::API_URL).to_timeout
62
+ assert_equal :timeout, Europe::Vat.validate('DE115235681')
63
+ WebMock.disable!
64
+ end
65
+
66
+ def test_connection_refused
67
+ WebMock.enable!
68
+ stub_request(:post, Europe::Vat::API_URL).to_raise(Errno::ECONNREFUSED)
69
+ assert_equal :service_unavailable, Europe::Vat.validate('DE115235681')
70
+ WebMock.disable!
71
+ end
72
+
73
+ def test_server_error_without_body
74
+ WebMock.enable!
75
+ stub_request(:post, Europe::Vat::API_URL)
76
+ .to_return(status: 500, body: '')
77
+ assert_equal :service_error, Europe::Vat.validate('DE115235681')
78
+ WebMock.disable!
79
+ end
80
+
81
+ def test_service_unavailable_error
82
+ assert_vies_error 500, SERVICE_UNAVAILABLE_BODY,
83
+ 'DE115235681', :service_unavailable
84
+ end
44
85
 
45
- stub_request(:get, 'http://ec.europa.eu/taxation_customs/vies/services/checkVatService')
46
- .with(headers: { 'Accept' => '*/*', 'User-Agent' => 'Ruby' })
47
- .to_return(status: 421, body: '')
48
- Europe::Vat.validate('DE115235681')
86
+ def test_ms_unavailable_error
87
+ assert_vies_error 500, MS_UNAVAILABLE_BODY,
88
+ 'DE115235681', :ms_unavailable
89
+ end
90
+
91
+ def test_rate_limited_global
92
+ assert_vies_error 500, GLOBAL_RATE_LIMITED_BODY,
93
+ 'DE115235681', :rate_limited
94
+ end
95
+
96
+ def test_rate_limited_member_state
97
+ assert_vies_error 500, MS_RATE_LIMITED_BODY,
98
+ 'DE115235681', :rate_limited
99
+ end
100
+
101
+ def test_invalid_input_error
102
+ assert_vies_error 400, INVALID_INPUT_BODY,
103
+ 'XX123456789', :invalid_input
104
+ end
105
+
106
+ def test_vies_timeout_error
107
+ assert_vies_error 500, VIES_TIMEOUT_BODY,
108
+ 'DE115235681', :timeout
109
+ end
110
+
111
+ private
112
+
113
+ def assert_vies_error(status, body, vat, expected)
114
+ WebMock.enable!
115
+ stub_request(:post, Europe::Vat::API_URL)
116
+ .to_return(
117
+ status: status, body: body,
118
+ headers: { 'Content-Type' => 'application/json' }
119
+ )
120
+ assert_equal expected, Europe::Vat.validate(vat)
49
121
  WebMock.disable!
50
122
  end
51
123
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: europe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.26
4
+ version: 0.0.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gem shards
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-15 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: byebug
@@ -165,6 +165,7 @@ files:
165
165
  - lib/europe/postal/postal.rb
166
166
  - lib/europe/rake_task.rb
167
167
  - lib/europe/tasks/eurostat.rake
168
+ - lib/europe/vat/batch.rb
168
169
  - lib/europe/vat/format.rb
169
170
  - lib/europe/vat/rates.rb
170
171
  - lib/europe/vat/vat.rb
@@ -174,6 +175,7 @@ files:
174
175
  - test/europe/currency/exchange_rates_test.rb
175
176
  - test/europe/eurostat/eurostat_test.rb
176
177
  - test/europe/postal/format_test.rb
178
+ - test/europe/vat/batch_validation_test.rb
177
179
  - test/europe/vat/format_test.rb
178
180
  - test/europe/vat/rates_test.rb
179
181
  - test/europe/vat/validation_test.rb
@@ -199,7 +201,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
199
201
  - !ruby/object:Gem::Version
200
202
  version: '0'
201
203
  requirements: []
202
- rubygems_version: 3.5.23
204
+ rubygems_version: 3.5.9
203
205
  signing_key:
204
206
  specification_version: 4
205
207
  summary: Europe is a gem for retrieving and validating EU government data.
@@ -209,6 +211,7 @@ test_files:
209
211
  - test/europe/currency/exchange_rates_test.rb
210
212
  - test/europe/eurostat/eurostat_test.rb
211
213
  - test/europe/postal/format_test.rb
214
+ - test/europe/vat/batch_validation_test.rb
212
215
  - test/europe/vat/format_test.rb
213
216
  - test/europe/vat/rates_test.rb
214
217
  - test/europe/vat/validation_test.rb