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 +4 -4
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +9 -0
- data/README.md +29 -0
- data/lib/europe/vat/batch.rb +158 -0
- data/lib/europe/vat/rates.rb +13 -28
- data/lib/europe/vat/vat.rb +71 -54
- data/lib/europe/version.rb +1 -1
- data/test/europe/vat/batch_validation_test.rb +203 -0
- data/test/europe/vat/validation_test.rb +82 -10
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 988b8443805f27f2cb40846b2a2cef3cee76ac464f5c134f833c0ba63acdfce7
|
|
4
|
+
data.tar.gz: '087c687254747b8d653dad7f33de58f52474acfe5211b8a17a1e3be8d3d12fcb'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/europe/vat/rates.rb
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
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:
|
|
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:
|
|
13
|
+
RO: 21.0, SE: 25.0, SI: 22.0, SK: 23.0
|
|
14
14
|
}.freeze
|
|
15
|
-
RATES_URL = 'https://
|
|
16
|
-
'/
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
data/lib/europe/vat/vat.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
15
|
-
'services/checkVatService'
|
|
15
|
+
API_URL = 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number'
|
|
16
16
|
HEADERS = {
|
|
17
|
-
'Content-Type' => '
|
|
18
|
-
'
|
|
17
|
+
'Content-Type' => 'application/json',
|
|
18
|
+
'Accept' => 'application/json'
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 :
|
|
31
|
+
return :invalid_input if number.size < 4
|
|
37
32
|
|
|
38
33
|
response = send_request(number[0..1], number[2..-1])
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
48
|
-
:
|
|
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 =
|
|
80
|
+
body = JSON.parse(response.body)
|
|
53
81
|
{
|
|
54
|
-
valid:
|
|
55
|
-
country_code:
|
|
56
|
-
vat_number:
|
|
57
|
-
request_date: convert_date(
|
|
58
|
-
name:
|
|
59
|
-
address:
|
|
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(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
data/lib/europe/version.rb
CHANGED
|
@@ -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 :
|
|
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
|
|
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
|
|
56
|
+
unless validate_correct_vat.is_a?(Symbol)
|
|
38
57
|
end
|
|
39
58
|
|
|
40
|
-
def
|
|
59
|
+
def test_timeout
|
|
41
60
|
WebMock.enable!
|
|
42
|
-
stub_request(:
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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
|