active_shipping 1.0.0.pre4 → 1.0.0

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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +0 -0
  4. data/.yardopts +0 -1
  5. data/CHANGELOG.md +17 -0
  6. data/CONTRIBUTING.md +2 -2
  7. data/Gemfile.activesupport32 +1 -0
  8. data/README.md +1 -1
  9. data/Rakefile +1 -1
  10. data/active_shipping.gemspec +2 -2
  11. data/lib/active_shipping.rb +2 -3
  12. data/lib/active_shipping/carriers.rb +1 -0
  13. data/lib/active_shipping/carriers/canada_post_pws.rb +281 -266
  14. data/lib/active_shipping/carriers/correios.rb +285 -0
  15. data/lib/active_shipping/carriers/fedex.rb +205 -199
  16. data/lib/active_shipping/carriers/stamps.rb +218 -219
  17. data/lib/active_shipping/carriers/ups.rb +287 -48
  18. data/lib/active_shipping/carriers/usps.rb +3 -3
  19. data/lib/active_shipping/delivery_date_estimate.rb +21 -0
  20. data/lib/active_shipping/delivery_date_estimates_response.rb +11 -0
  21. data/lib/active_shipping/errors.rb +20 -1
  22. data/lib/active_shipping/location.rb +0 -5
  23. data/lib/active_shipping/response.rb +0 -15
  24. data/lib/active_shipping/version.rb +1 -1
  25. data/test/credentials.yml +11 -3
  26. data/test/fixtures/xml/correios/book_response.xml +13 -0
  27. data/test/fixtures/xml/correios/book_response_invalid.xml +13 -0
  28. data/test/fixtures/xml/correios/clothes_response.xml +43 -0
  29. data/test/fixtures/xml/correios/poster_response.xml +23 -0
  30. data/test/fixtures/xml/correios/shoes_response.xml +43 -0
  31. data/test/fixtures/xml/fedex/tracking_request.xml +13 -11
  32. data/test/fixtures/xml/fedex/tracking_response_bad_tracking_number.xml +20 -0
  33. data/test/fixtures/xml/fedex/tracking_response_delivered_at_door.xml +254 -0
  34. data/test/fixtures/xml/fedex/tracking_response_delivered_at_facility.xml +403 -0
  35. data/test/fixtures/xml/fedex/tracking_response_delivered_with_signature.xml +269 -0
  36. data/test/fixtures/xml/fedex/tracking_response_in_transit.xml +127 -0
  37. data/test/fixtures/xml/fedex/tracking_response_multiple_results.xml +100 -0
  38. data/test/fixtures/xml/fedex/tracking_response_not_found.xml +52 -0
  39. data/test/fixtures/xml/fedex/tracking_response_shipment_exception.xml +209 -0
  40. data/test/fixtures/xml/ups/delivery_dates_response.xml +140 -0
  41. data/test/fixtures/xml/ups/package_exceeds_maximum_length.xml +12 -0
  42. data/test/fixtures/xml/ups/rate_single_service.xml +54 -0
  43. data/test/fixtures/xml/ups/rescheduled_shipment.xml +204 -0
  44. data/test/fixtures/xml/ups/test_real_home_as_residential_destination_response.xml +290 -1
  45. data/test/fixtures/xml/usps/delivered_extended_tracking_response.xml +11 -0
  46. data/test/remote/canada_post_pws_platform_test.rb +35 -22
  47. data/test/remote/canada_post_pws_test.rb +32 -40
  48. data/test/remote/correios_test.rb +83 -0
  49. data/test/remote/fedex_test.rb +95 -13
  50. data/test/remote/stamps_test.rb +1 -0
  51. data/test/remote/ups_test.rb +77 -40
  52. data/test/remote/usps_test.rb +13 -1
  53. data/test/test_helper.rb +12 -2
  54. data/test/unit/carriers/canada_post_pws_rating_test.rb +66 -59
  55. data/test/unit/carriers/canada_post_pws_shipping_test.rb +34 -23
  56. data/test/unit/carriers/correios_test.rb +244 -0
  57. data/test/unit/carriers/fedex_test.rb +161 -156
  58. data/test/unit/carriers/ups_test.rb +193 -1
  59. data/test/unit/carriers/usps_test.rb +14 -0
  60. data/test/unit/location_test.rb +0 -10
  61. metadata +63 -46
  62. metadata.gz.sig +0 -0
  63. data/lib/vendor/test_helper.rb +0 -6
  64. data/lib/vendor/xml_node/README +0 -36
  65. data/lib/vendor/xml_node/Rakefile +0 -21
  66. data/lib/vendor/xml_node/benchmark/bench_generation.rb +0 -30
  67. data/lib/vendor/xml_node/init.rb +0 -1
  68. data/lib/vendor/xml_node/lib/xml_node.rb +0 -221
  69. data/lib/vendor/xml_node/test/test_generating.rb +0 -89
  70. data/lib/vendor/xml_node/test/test_parsing.rb +0 -40
  71. data/test/fixtures/xml/fedex/tracking_response.xml +0 -151
  72. data/test/fixtures/xml/fedex/tracking_response_empty_destination.xml +0 -76
  73. data/test/fixtures/xml/fedex/tracking_response_no_destination.xml +0 -139
  74. data/test/fixtures/xml/fedex/tracking_response_no_ship_time.xml +0 -150
  75. data/test/fixtures/xml/fedex/tracking_response_with_estimated_delivery_date.xml +0 -95
  76. data/test/fixtures/xml/fedex/tracking_response_with_shipper_address.xml +0 -71
@@ -0,0 +1,285 @@
1
+ # -*- encoding utf-8 -*-
2
+
3
+ module ActiveShipping
4
+ class Correios < Carrier
5
+
6
+ cattr_reader :name
7
+ @@name = "Correios do Brasil"
8
+
9
+ def find_rates(origin, destination, packages, options = {})
10
+ options = @options.merge(options)
11
+
12
+ request = CorreiosRequest.new(origin, destination, packages, options)
13
+ response = request.create_response(perform(request.urls))
14
+
15
+ response
16
+ end
17
+
18
+ def self.available_services
19
+ AVAILABLE_SERVICES
20
+ end
21
+
22
+ protected
23
+
24
+ DEFAULT_SERVICES = [41106, 40010]
25
+ AVAILABLE_SERVICES = {
26
+ 41106 => 'PAC sem contrato',
27
+ 41068 => 'PAC com contrato',
28
+ 41300 => 'PAC para grandes formatos',
29
+ 40010 => 'SEDEX sem contrato',
30
+ 40045 => 'SEDEX a Cobrar, sem contrato',
31
+ 40126 => 'SEDEX a Cobrar, com contrato',
32
+ 40215 => 'SEDEX 10, sem contrato',
33
+ 40290 => 'SEDEX Hoje, sem contrato',
34
+ 40096 => 'SEDEX com contrato',
35
+ 40436 => 'SEDEX com contrato',
36
+ 40444 => 'SEDEX com contrato',
37
+ 40568 => 'SEDEX com contrato',
38
+ 40606 => 'SEDEX com contrato',
39
+ 81019 => 'e-SEDEX, com contrato',
40
+ 81027 => 'e-SEDEX Prioritário, com contrato',
41
+ 81035 => 'e-SEDEX Express, com contrato',
42
+ 81868 => '(Grupo 1) e-SEDEX, com contrato',
43
+ 81833 => '(Grupo 2) e-SEDEX, com contrato',
44
+ 81850 => '(Grupo 3) e-SEDEX, com contrato'
45
+ }.freeze
46
+
47
+ def perform(urls)
48
+ urls.map { |url| ssl_get(url) }
49
+ end
50
+
51
+ class CorreiosRequest
52
+
53
+ URL = "http://ws.correios.com.br/calculador/CalcPrecoPrazo.aspx"
54
+
55
+ RETURN_TYPE = 'xml'
56
+ RETURN_INFORMATION_TYPE = {
57
+ :prices => '1',
58
+ :time => '2',
59
+ :prices_and_time => '3'
60
+ }
61
+
62
+ attr_reader :origin, :destination, :urls
63
+
64
+ def initialize(origin, destination, packages, options)
65
+ @options = options
66
+ @origin = origin
67
+ @destination = destination
68
+
69
+ packages = packages.map do |package|
70
+ CorreiosPackage.new(package, 1)
71
+ end
72
+
73
+ @params = {
74
+ company_id: options[:company_id],
75
+ password: options[:password],
76
+ service_type: service_type,
77
+ origin_zip: origin.zip,
78
+ destination_zip: destination.zip,
79
+ mao_propria_extra: parse_boolean(options[:mao_propria_extra]),
80
+ declared_value_extra: parse_currency(options[:declared_value_extra]),
81
+ delivery_notice_extra: parse_boolean(options[:delivery_notice_extra]),
82
+ return_type: RETURN_TYPE,
83
+ return_information: RETURN_INFORMATION_TYPE[:prices]
84
+ }
85
+ @urls = packages.map { |package| create_url(package).to_s }
86
+ end
87
+
88
+ def create_response(raw_xmls)
89
+ correios_response = CorreiosResponse.new(self, raw_xmls)
90
+ correios_response.rate_response
91
+ end
92
+
93
+ private
94
+
95
+ def parse_boolean(param)
96
+ param ? 'S' : 'N'
97
+ end
98
+
99
+ def parse_currency(param)
100
+ param.nil? ? '0' : param.to_s.gsub('.', ',')
101
+ end
102
+
103
+ def build_params(package)
104
+ @params.merge(package.params)
105
+ end
106
+
107
+ def hash_query_string(params)
108
+ {
109
+ 'nCdEmpresa' => params[:company_id],
110
+ 'sDsSenha' => params[:password],
111
+ 'nCdServico' => params[:service_type],
112
+ 'sCepOrigem' => params[:origin_zip],
113
+ 'sCepDestino' => params[:destination_zip],
114
+ 'nVlPeso' => params[:weight],
115
+ 'nCdFormato' => params[:format],
116
+ 'nVlComprimento' => params[:length],
117
+ 'nVlAltura' => params[:height],
118
+ 'nVlLargura' => params[:width],
119
+ 'nVlDiametro' => params[:diameter],
120
+ 'sCdMaoPropria' => params[:mao_propria_extra],
121
+ 'nVlValorDeclarado' => params[:declared_value_extra],
122
+ 'sCdAvisoRecebimento' => params[:delivery_notice_extra],
123
+ 'nIndicaCalculo' => params[:return_information],
124
+ 'StrRetorno' => params[:return_type]
125
+ }
126
+ end
127
+
128
+ def query_string(package)
129
+ params = build_params(package)
130
+ hash_query_string(params)
131
+ end
132
+
133
+ def create_url(package)
134
+ scheme, userinfo, host, port, registry, path, opaque, query, fragment = URI::split(URL)
135
+
136
+ query ||= ""
137
+ query = CGI.parse(query)
138
+ .merge(query_string(package))
139
+ .to_query
140
+
141
+ URI::HTTP.new(scheme, userinfo, host, port, registry, path, opaque, query, fragment)
142
+ end
143
+
144
+ def service_type
145
+ @options[:services].nil? ? DEFAULT_SERVICES.join(',') : @options[:services].join(',')
146
+ end
147
+
148
+ end
149
+
150
+ class CorreiosRateResponse < ActiveShipping::RateResponse
151
+ attr_reader :raw_responses, :urls
152
+
153
+ def initialize(success, message, params = {}, options = {})
154
+ @raw_responses = options[:raw_responses]
155
+ @urls = options[:urls]
156
+ super
157
+ end
158
+ end
159
+
160
+ class CorreiosResponse
161
+
162
+ def initialize(request, raw_xmls)
163
+ @request = request
164
+ @raw_xmls = raw_xmls
165
+ @documents = raw_xmls.map { |xml| Nokogiri::XML(xml) }
166
+ end
167
+
168
+ def rate_response
169
+ @rates = rates
170
+ CorreiosRateResponse.new(true, nil, params_options, response_options)
171
+ rescue => error
172
+ CorreiosRateResponse.new(false, error.message, {}, response_options)
173
+ end
174
+
175
+ private
176
+
177
+ def response_options
178
+ {
179
+ :rates => @rates,
180
+ :raw_responses => @raw_xmls,
181
+ :urls => @request.urls
182
+ }
183
+ end
184
+
185
+ def params_options
186
+ { :responses => @documents }
187
+ end
188
+
189
+ def normalized_services
190
+ services = @documents.map { |document| document.root.elements }.flatten
191
+ services = services.group_by do |service_xml|
192
+ raise(error_message(service_xml)) if error?(service_xml)
193
+ service_code(service_xml)
194
+ end
195
+ end
196
+
197
+ def rates_array
198
+ services = normalized_services.map do |service_id, elements|
199
+ total_price = elements.sum { |element| price(element) }
200
+ { :service_code => service_id, :total_price => total_price, :currency => "BRL" }
201
+ end
202
+ end
203
+
204
+ def rates
205
+ rates_array.map { |rate_hash| create_rate_estimate(rate_hash) }
206
+ end
207
+
208
+ def create_rate_estimate(rate_hash)
209
+ RateEstimate.new(@request.origin, @request.destination, Correios.name, AVAILABLE_SERVICES[rate_hash[:service_code]], rate_hash)
210
+ end
211
+
212
+ def error?(xml_item)
213
+ error_id = error_id(xml_item)
214
+ !error_id.empty? && error_id != "0"
215
+ end
216
+
217
+ def error_id(xml_item)
218
+ xml_item.css('Erro').text
219
+ end
220
+
221
+ def error_message(xml_item)
222
+ xml_item.css('MsgErro').text
223
+ end
224
+
225
+ def service_code(xml_item)
226
+ xml_item.css('Codigo').text.to_i
227
+ end
228
+
229
+ def price(xml_item)
230
+ xml_item.css('Valor').text.gsub(',', '.').to_f
231
+ end
232
+
233
+ end
234
+
235
+ class CorreiosPackage
236
+ attr_reader :params
237
+
238
+ FORMAT = {
239
+ :package => 1,
240
+ :roll => 2,
241
+ :envelope => 3
242
+ }
243
+
244
+ def initialize(package, format)
245
+ @package = package
246
+
247
+ @params = {
248
+ :format => format,
249
+ :weight => weight,
250
+ :width => width,
251
+ :length => length,
252
+ :height => height(format),
253
+ :diameter => diameter
254
+ }
255
+ end
256
+
257
+ private
258
+
259
+ def weight
260
+ @package.kg
261
+ end
262
+
263
+ def width
264
+ @package.cm(:width)
265
+ end
266
+
267
+ def length
268
+ @package.cm(:length)
269
+ end
270
+
271
+ def height(format)
272
+ return 0 if format == FORMAT[:envelope]
273
+ return diameter if @package.cylinder?
274
+ @package.cm(:height)
275
+ end
276
+
277
+ def diameter
278
+ return 0 unless @package.cylinder?
279
+ @package.cm(:width)
280
+ end
281
+
282
+ end
283
+
284
+ end
285
+ end
@@ -1,12 +1,9 @@
1
- # FedEx module by Jimmy Baker
2
- # http://github.com/jimmyebaker
3
-
4
- require 'date'
5
1
  module ActiveShipping
6
- # :key is your developer API key
7
- # :password is your API password
8
- # :account is your FedEx account number
9
- # :login is your meter number
2
+
3
+ # FedEx carrier implementation.
4
+ #
5
+ # FedEx module by Jimmy Baker (http://github.com/jimmyebaker)
6
+ # Documentation can be found here: http://images.fedex.com/us/developer/product/WebServices/MyWebHelp/PropDevGuide.pdf
10
7
  class FedEx < Carrier
11
8
  self.retry_safe = true
12
9
 
@@ -87,7 +84,8 @@ module ActiveShipping
87
84
  'ground_customer_reference' => 'GROUND_CUSTOMER_REFERENCE',
88
85
  'ground_po' => 'GROUND_PO',
89
86
  'express_reference' => 'EXPRESS_REFERENCE',
90
- 'express_mps_master' => 'EXPRESS_MPS_MASTER'
87
+ 'express_mps_master' => 'EXPRESS_MPS_MASTER',
88
+ 'shipper_reference' => 'SHIPPER_REFERENCE',
91
89
  }
92
90
 
93
91
  TRANSIT_TIMES = %w(UNKNOWN ONE_DAY TWO_DAYS THREE_DAYS FOUR_DAYS FIVE_DAYS SIX_DAYS SEVEN_DAYS EIGHT_DAYS NINE_DAYS TEN_DAYS ELEVEN_DAYS TWELVE_DAYS THIRTEEN_DAYS FOURTEEN_DAYS FIFTEEN_DAYS SIXTEEN_DAYS SEVENTEEN_DAYS EIGHTEEN_DAYS)
@@ -143,9 +141,8 @@ module ActiveShipping
143
141
  rate_request = build_rate_request(origin, destination, packages, options)
144
142
 
145
143
  xml = commit(save_request(rate_request), (options[:test] || false))
146
- response = remove_version_prefix(xml)
147
144
 
148
- parse_rate_response(origin, destination, packages, response, options)
145
+ parse_rate_response(origin, destination, packages, xml, options)
149
146
  end
150
147
 
151
148
  def find_tracking_info(tracking_number, options = {})
@@ -153,8 +150,7 @@ module ActiveShipping
153
150
 
154
151
  tracking_request = build_tracking_request(tracking_number, options)
155
152
  xml = commit(save_request(tracking_request), (options[:test] || false))
156
- response = remove_version_prefix(xml)
157
- parse_tracking_response(response, options)
153
+ parse_tracking_response(xml, options)
158
154
  end
159
155
 
160
156
  protected
@@ -162,90 +158,90 @@ module ActiveShipping
162
158
  def build_rate_request(origin, destination, packages, options = {})
163
159
  imperial = %w(US LR MM).include?(origin.country_code(:alpha2))
164
160
 
165
- xml_request = XmlNode.new('RateRequest', 'xmlns' => 'http://fedex.com/ws/rate/v13') do |root_node|
166
- root_node << build_request_header
167
- root_node << build_version_node
161
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
162
+ xml.RateRequest(xmlns: 'http://fedex.com/ws/rate/v13') do
163
+ build_request_header(xml)
164
+ build_version_node(xml, 'crs', 13, 0 ,0)
168
165
 
169
- # Returns delivery dates
170
- root_node << XmlNode.new('ReturnTransitAndCommit', true)
171
- # Returns saturday delivery shipping options when available
172
- root_node << XmlNode.new('VariableOptions', 'SATURDAY_DELIVERY')
166
+ # Returns delivery dates
167
+ xml.ReturnTransitAndCommit(true)
173
168
 
174
- root_node << XmlNode.new('RequestedShipment') do |rs|
175
- rs << XmlNode.new('ShipTimestamp', ship_timestamp(options[:turn_around_time]))
169
+ # Returns saturday delivery shipping options when available
170
+ xml.VariableOptions('SATURDAY_DELIVERY')
176
171
 
177
- freight = has_freight?(options)
172
+ xml.RequestedShipment do
173
+ xml.ShipTimestamp(ship_timestamp(options[:turn_around_time]).iso8601(0))
178
174
 
179
- unless freight
180
- # fedex api wants this up here otherwise request returns an error
181
- rs << XmlNode.new('DropoffType', options[:dropoff_type] || 'REGULAR_PICKUP')
182
- rs << XmlNode.new('PackagingType', options[:packaging_type] || 'YOUR_PACKAGING')
183
- end
184
-
185
- rs << build_location_node('Shipper', (options[:shipper] || origin))
186
- rs << build_location_node('Recipient', destination)
187
- if options[:shipper] and options[:shipper] != origin
188
- rs << build_location_node('Origin', origin)
189
- end
175
+ freight = has_freight?(options)
190
176
 
191
- if freight
192
- # build xml for freight rate requests
193
- freight_options = options[:freight]
194
- rs << build_shipping_charges_payment_node(freight_options)
195
- rs << build_freight_shipment_detail_node(freight_options, packages, imperial)
196
- rs << build_rate_request_types_node
197
- else
198
- # build xml for non-freight rate requests
199
- rs << XmlNode.new('SmartPostDetail') do |spd|
200
- spd << XmlNode.new('Indicia', options[:smart_post_indicia] || 'PARCEL_SELECT')
201
- spd << XmlNode.new('HubId', options[:smart_post_hub_id] || 5902) # default to LA
177
+ unless freight
178
+ # fedex api wants this up here otherwise request returns an error
179
+ xml.DropoffType(options[:dropoff_type] || 'REGULAR_PICKUP')
180
+ xml.PackagingType(options[:packaging_type] || 'YOUR_PACKAGING')
202
181
  end
203
182
 
204
- rs << build_rate_request_types_node
205
- rs << XmlNode.new('PackageCount', packages.size)
206
- rs << build_packages_nodes(packages, imperial)
183
+ build_location_node(xml, 'Shipper', options[:shipper] || origin)
184
+ build_location_node(xml, 'Recipient', destination)
185
+ if options[:shipper] && options[:shipper] != origin
186
+ build_location_node(xml, 'Origin', origin)
187
+ end
207
188
 
189
+ if freight
190
+ freight_options = options[:freight]
191
+ build_shipping_charges_payment_node(xml, freight_options)
192
+ build_freight_shipment_detail_node(xml, freight_options, packages, imperial)
193
+ build_rate_request_types_node(xml)
194
+ else
195
+ xml.SmartPostDetail do
196
+ xml.Indicia(options[:smart_post_indicia] || 'PARCEL_SELECT')
197
+ xml.HubId(options[:smart_post_hub_id] || 5902) # default to LA
198
+ end
199
+
200
+ build_rate_request_types_node(xml)
201
+ xml.PackageCount(packages.size)
202
+ build_packages_nodes(xml, packages, imperial)
203
+ end
208
204
  end
209
205
  end
210
206
  end
211
- xml_request.to_s
207
+ xml_builder.to_xml
212
208
  end
213
209
 
214
- def build_packages_nodes(packages, imperial)
210
+ def build_packages_nodes(xml, packages, imperial)
215
211
  packages.map do |pkg|
216
- XmlNode.new('RequestedPackageLineItems') do |rps|
217
- rps << XmlNode.new('GroupPackageCount', 1)
218
- rps << build_package_weight_node(pkg, imperial)
219
- rps << build_package_dimensions_node(pkg, imperial)
212
+ xml.RequestedPackageLineItems do
213
+ xml.GroupPackageCount(1)
214
+ build_package_weight_node(xml, pkg, imperial)
215
+ build_package_dimensions_node(xml, pkg, imperial)
220
216
  end
221
217
  end
222
218
  end
223
219
 
224
- def build_shipping_charges_payment_node(freight_options)
225
- XmlNode.new('ShippingChargesPayment') do |shipping_charges_payment|
226
- shipping_charges_payment << XmlNode.new('PaymentType', freight_options[:payment_type])
227
- shipping_charges_payment << XmlNode.new('Payor') do |payor|
228
- payor << XmlNode.new('ResponsibleParty') do |responsible_party|
220
+ def build_shipping_charges_payment_node(xml, freight_options)
221
+ xml.ShippingChargesPayment do
222
+ xml.PaymentType(freight_options[:payment_type])
223
+ xml.Payor do
224
+ xml.ResponsibleParty do
229
225
  # TODO: case of different freight account numbers?
230
- responsible_party << XmlNode.new('AccountNumber', freight_options[:account])
226
+ xml.AccountNumber(freight_options[:account])
231
227
  end
232
228
  end
233
229
  end
234
230
  end
235
231
 
236
- def build_freight_shipment_detail_node(freight_options, packages, imperial)
237
- XmlNode.new('FreightShipmentDetail') do |freight_shipment_detail|
232
+ def build_freight_shipment_detail_node(xml, freight_options, packages, imperial)
233
+ xml.FreightShipmentDetail do
238
234
  # TODO: case of different freight account numbers?
239
- freight_shipment_detail << XmlNode.new('FedExFreightAccountNumber', freight_options[:account])
240
- freight_shipment_detail << build_location_node('FedExFreightBillingContactAndAddress', freight_options[:billing_location])
241
- freight_shipment_detail << XmlNode.new('Role', freight_options[:role])
235
+ xml.FedExFreightAccountNumber(freight_options[:account])
236
+ build_location_node(xml, 'FedExFreightBillingContactAndAddress', freight_options[:billing_location])
237
+ xml.Role(freight_options[:role])
242
238
 
243
239
  packages.each do |pkg|
244
- freight_shipment_detail << XmlNode.new('LineItems') do |line_items|
245
- line_items << XmlNode.new('FreightClass', freight_options[:freight_class])
246
- line_items << XmlNode.new('Packaging', freight_options[:packaging])
247
- line_items << build_package_weight_node(pkg, imperial)
248
- line_items << build_package_dimensions_node(pkg, imperial)
240
+ xml.LineItems do
241
+ xml.FreightClass(freight_options[:freight_class])
242
+ xml.Packaging(freight_options[:packaging])
243
+ build_package_weight_node(xml, pkg, imperial)
244
+ build_package_dimensions_node(xml, pkg, imperial)
249
245
  end
250
246
  end
251
247
  end
@@ -255,125 +251,116 @@ module ActiveShipping
255
251
  options[:freight] && options[:freight].present?
256
252
  end
257
253
 
258
- def build_package_weight_node(pkg, imperial)
259
- XmlNode.new('Weight') do |tw|
260
- tw << XmlNode.new('Units', imperial ? 'LB' : 'KG')
261
- tw << XmlNode.new('Value', [((imperial ? pkg.lbs : pkg.kgs).to_f * 1000).round / 1000.0, 0.1].max)
262
- end
263
- end
264
-
265
- def build_version_node
266
- XmlNode.new('Version') do |version_node|
267
- version_node << XmlNode.new('ServiceId', 'crs')
268
- version_node << XmlNode.new('Major', '13')
269
- version_node << XmlNode.new('Intermediate', '0')
270
- version_node << XmlNode.new('Minor', '0')
254
+ def build_package_weight_node(xml, pkg, imperial)
255
+ xml.Weight do
256
+ xml.Units(imperial ? 'LB' : 'KG')
257
+ xml.Value([((imperial ? pkg.lbs : pkg.kgs).to_f * 1000).round / 1000.0, 0.1].max)
271
258
  end
272
259
  end
273
260
 
274
- def build_package_dimensions_node(pkg, imperial)
275
- XmlNode.new('Dimensions') do |dimensions|
261
+ def build_package_dimensions_node(xml, pkg, imperial)
262
+ xml.Dimensions do
276
263
  [:length, :width, :height].each do |axis|
277
264
  value = ((imperial ? pkg.inches(axis) : pkg.cm(axis)).to_f * 1000).round / 1000.0 # 3 decimals
278
- dimensions << XmlNode.new(axis.to_s.capitalize, value.ceil)
265
+ xml.public_send(axis.to_s.capitalize, value.ceil)
279
266
  end
280
- dimensions << XmlNode.new('Units', imperial ? 'IN' : 'CM')
267
+ xml.Units(imperial ? 'IN' : 'CM')
281
268
  end
282
269
  end
283
270
 
284
- def build_rate_request_types_node(type = 'ACCOUNT')
285
- XmlNode.new('RateRequestTypes', type)
271
+ def build_rate_request_types_node(xml, type = 'ACCOUNT')
272
+ xml.RateRequestTypes(type)
286
273
  end
287
274
 
288
275
  def build_tracking_request(tracking_number, options = {})
289
- xml_request = XmlNode.new('TrackRequest', 'xmlns' => 'http://fedex.com/ws/track/v3') do |root_node|
290
- root_node << build_request_header
291
-
292
- # Version
293
- root_node << XmlNode.new('Version') do |version_node|
294
- version_node << XmlNode.new('ServiceId', 'trck')
295
- version_node << XmlNode.new('Major', '3')
296
- version_node << XmlNode.new('Intermediate', '0')
297
- version_node << XmlNode.new('Minor', '0')
298
- end
276
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
277
+ xml.TrackRequest(xmlns: 'http://fedex.com/ws/track/v7') do
278
+ build_request_header(xml)
279
+ build_version_node(xml, 'trck', 7, 0, 0)
280
+
281
+ xml.SelectionDetails do
282
+ xml.PackageIdentifier do
283
+ xml.Type(PACKAGE_IDENTIFIER_TYPES[options[:package_identifier_type] || 'tracking_number'])
284
+ xml.Value(tracking_number)
285
+ end
299
286
 
300
- root_node << XmlNode.new('PackageIdentifier') do |package_node|
301
- package_node << XmlNode.new('Value', tracking_number)
302
- package_node << XmlNode.new('Type', PACKAGE_IDENTIFIER_TYPES[options['package_identifier_type'] || 'tracking_number'])
303
- end
287
+ xml.ShipDateRangeBegin(options[:ship_date_range_begin]) if options[:ship_date_range_begin]
288
+ xml.ShipDateRangeEnd(options[:ship_date_range_end]) if options[:ship_date_range_end]
289
+ xml.TrackingNumberUniqueIdentifier(options[:unique_identifier]) if options[:unique_identifier]
290
+ end
304
291
 
305
- root_node << XmlNode.new('ShipDateRangeBegin', options['ship_date_range_begin']) if options['ship_date_range_begin']
306
- root_node << XmlNode.new('ShipDateRangeEnd', options['ship_date_range_end']) if options['ship_date_range_end']
307
- root_node << XmlNode.new('IncludeDetailedScans', 1)
292
+ xml.ProcessingOptions('INCLUDE_DETAILED_SCANS')
293
+ end
308
294
  end
309
- xml_request.to_s
295
+ xml_builder.to_xml
310
296
  end
311
297
 
312
- def build_request_header
313
- web_authentication_detail = XmlNode.new('WebAuthenticationDetail') do |wad|
314
- wad << XmlNode.new('UserCredential') do |uc|
315
- uc << XmlNode.new('Key', @options[:key])
316
- uc << XmlNode.new('Password', @options[:password])
298
+ def build_request_header(xml)
299
+ xml.WebAuthenticationDetail do
300
+ xml.UserCredential do
301
+ xml.Key(@options[:key])
302
+ xml.Password(@options[:password])
317
303
  end
318
304
  end
319
305
 
320
- client_detail = XmlNode.new('ClientDetail') do |cd|
321
- cd << XmlNode.new('AccountNumber', @options[:account])
322
- cd << XmlNode.new('MeterNumber', @options[:login])
306
+ xml.ClientDetail do
307
+ xml.AccountNumber(@options[:account])
308
+ xml.MeterNumber(@options[:login])
323
309
  end
324
310
 
325
- trasaction_detail = XmlNode.new('TransactionDetail') do |td|
326
- td << XmlNode.new('CustomerTransactionId', @options[:transaction_id] || 'ActiveShipping') # TODO: Need to do something better with this..
311
+ xml.TransactionDetail do
312
+ xml.CustomerTransactionId(@options[:transaction_id] || 'ActiveShipping') # TODO: Need to do something better with this...
327
313
  end
328
-
329
- [web_authentication_detail, client_detail, trasaction_detail]
330
314
  end
331
315
 
332
- def build_location_node(name, location)
333
- XmlNode.new(name) do |xml_node|
334
- xml_node << XmlNode.new('Address') do |address_node|
335
- address_node << XmlNode.new('StreetLines', location.address1) if location.address1
336
- address_node << XmlNode.new('StreetLines', location.address2) if location.address2
337
- address_node << XmlNode.new('City', location.city) if location.city
338
- address_node << XmlNode.new('PostalCode', location.postal_code)
339
- address_node << XmlNode.new("CountryCode", location.country_code(:alpha2))
316
+ def build_version_node(xml, service_id, major, intermediate, minor)
317
+ xml.Version do
318
+ xml.ServiceId(service_id)
319
+ xml.Major(major)
320
+ xml.Intermediate(intermediate)
321
+ xml.Minor(minor)
322
+ end
323
+ end
340
324
 
341
- address_node << XmlNode.new("Residential", true) unless location.commercial?
325
+ def build_location_node(xml, name, location)
326
+ xml.public_send(name) do
327
+ xml.Address do
328
+ xml.StreetLines(location.address1) if location.address1
329
+ xml.StreetLines(location.address2) if location.address2
330
+ xml.City(location.city) if location.city
331
+ xml.PostalCode(location.postal_code)
332
+ xml.CountryCode(location.country_code(:alpha2))
333
+ xml.Residential(true) unless location.commercial?
342
334
  end
343
335
  end
344
336
  end
345
337
 
346
338
  def parse_rate_response(origin, destination, packages, response, options)
347
- rate_estimates = []
348
-
349
- xml = build_document(response)
350
- root_node = xml.elements['RateReply']
339
+ xml = build_document(response, 'RateReply')
351
340
 
352
341
  success = response_success?(xml)
353
342
  message = response_message(xml)
354
343
 
355
- raise ActiveShipping::ResponseContentError.new(StandardError.new('Invalid document'), xml) unless root_node
356
-
357
- root_node.elements.each('RateReplyDetails') do |rated_shipment|
358
- service_code = rated_shipment.get_text('ServiceType').to_s
359
- is_saturday_delivery = rated_shipment.get_text('AppliedOptions').to_s == 'SATURDAY_DELIVERY'
344
+ rate_estimates = xml.root.css('> RateReplyDetails').map do |rated_shipment|
345
+ service_code = rated_shipment.at('ServiceType').text
346
+ is_saturday_delivery = rated_shipment.at('AppliedOptions').try(:text) == 'SATURDAY_DELIVERY'
360
347
  service_type = is_saturday_delivery ? "#{service_code}_SATURDAY_DELIVERY" : service_code
361
348
 
362
- transit_time = rated_shipment.get_text('TransitTime').to_s if service_code == "FEDEX_GROUND"
363
- max_transit_time = rated_shipment.get_text('MaximumTransitTime').to_s if service_code == "FEDEX_GROUND"
349
+ transit_time = rated_shipment.at('TransitTime').text if service_code == "FEDEX_GROUND"
350
+ max_transit_time = rated_shipment.at('MaximumTransitTime').try(:text) if service_code == "FEDEX_GROUND"
364
351
 
365
- delivery_timestamp = rated_shipment.get_text('DeliveryTimestamp').to_s
352
+ delivery_timestamp = rated_shipment.at('DeliveryTimestamp').try(:text)
366
353
 
367
354
  delivery_range = delivery_range_from(transit_time, max_transit_time, delivery_timestamp, options)
368
355
 
369
- currency = rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').to_s
370
- rate_estimates << RateEstimate.new(origin, destination, @@name,
371
- self.class.service_name_for_code(service_type),
372
- :service_code => service_code,
373
- :total_price => rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').to_s.to_f,
374
- :currency => currency,
375
- :packages => packages,
376
- :delivery_range => delivery_range)
356
+ currency = rated_shipment.at('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').text
357
+ RateEstimate.new(origin, destination, @@name,
358
+ self.class.service_name_for_code(service_type),
359
+ :service_code => service_code,
360
+ :total_price => rated_shipment.at('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').text.to_f,
361
+ :currency => currency,
362
+ :packages => packages,
363
+ :delivery_range => delivery_range)
377
364
  end
378
365
 
379
366
  if rate_estimates.empty?
@@ -413,8 +400,7 @@ module ActiveShipping
413
400
  end
414
401
 
415
402
  def parse_tracking_response(response, options)
416
- xml = build_document(response)
417
- root_node = xml.elements['TrackReply']
403
+ xml = build_document(response, 'TrackReply')
418
404
 
419
405
  success = response_success?(xml)
420
406
  message = response_message(xml)
@@ -424,23 +410,47 @@ module ActiveShipping
424
410
  delivery_signature = nil
425
411
  shipment_events = []
426
412
 
427
- tracking_details = root_node.elements['TrackDetails']
428
- tracking_number = tracking_details.get_text('TrackingNumber').to_s
429
- status_code = tracking_details.get_text('StatusCode').to_s
430
- status_description = tracking_details.get_text('StatusDescription').to_s
431
- status = TRACKING_STATUS_CODES[status_code]
413
+ all_tracking_details = xml.root.xpath('CompletedTrackDetails/TrackDetails')
414
+ tracking_details = case all_tracking_details.length
415
+ when 1
416
+ all_tracking_details.first
417
+ when 0
418
+ raise ActiveShipping::Error, "The response did not contain tracking details"
419
+ else
420
+ all_unique_identifiers = xml.root.xpath('CompletedTrackDetails/TrackDetails/TrackingNumberUniqueIdentifier').map(&:text)
421
+ raise ActiveShipping::Error, "Multiple matches were found. Specify a unqiue identifier: #{all_unique_identifiers.join(', ')}"
422
+ end
423
+
432
424
 
433
- if status_code == 'DL' && tracking_details.get_text('SignatureProofOfDeliveryAvailable').to_s == 'true'
434
- delivery_signature = tracking_details.get_text('DeliverySignatureName').to_s
425
+ first_notification = tracking_details.at('Notification')
426
+ if first_notification.at('Severity').text == 'ERROR'
427
+ case first_notification.at('Code').text
428
+ when '9040'
429
+ raise ActiveShipping::ShipmentNotFound, first_notification.at('Message').text
430
+ else
431
+ raise ActiveShipping::ResponseContentError, first_notification.at('Message').text
432
+ end
435
433
  end
436
434
 
437
- origin_node = tracking_details.elements['OriginLocationAddress']
435
+ tracking_number = tracking_details.at('TrackingNumber').text
436
+ status_detail = tracking_details.at('StatusDetail')
437
+ if status_detail.nil?
438
+ raise ActiveShipping::Error, "Tracking response does not contain status information"
439
+ end
440
+
441
+ status_code = status_detail.at('Code').text
442
+ status_description = (status_detail.at('AncillaryDetails/ReasonDescription') || status_detail.at('Description')).text
443
+ status = TRACKING_STATUS_CODES[status_code]
438
444
 
439
- if origin_node
445
+ if status_code == 'DL' && tracking_details.at('AvailableImages').try(:text) == 'SIGNATURE_PROOF_OF_DELIVERY'
446
+ delivery_signature = tracking_details.at('DeliverySignatureName').text
447
+ end
448
+
449
+ if origin_node = tracking_details.at('OriginLocationAddress')
440
450
  origin = Location.new(
441
- :country => origin_node.get_text('CountryCode').to_s,
442
- :province => origin_node.get_text('StateOrProvinceCode').to_s,
443
- :city => origin_node.get_text('City').to_s
451
+ :country => origin_node.at('CountryCode').text,
452
+ :province => origin_node.at('StateOrProvinceCode').text,
453
+ :city => origin_node.at('City').text
444
454
  )
445
455
  end
446
456
 
@@ -451,19 +461,19 @@ module ActiveShipping
451
461
  actual_delivery_time = extract_timestamp(tracking_details, 'ActualDeliveryTimestamp')
452
462
  scheduled_delivery_time = extract_timestamp(tracking_details, 'EstimatedDeliveryTimestamp')
453
463
 
454
- tracking_details.elements.each('Events') do |event|
455
- address = event.elements['Address']
464
+ tracking_details.xpath('Events').each do |event|
465
+ address = event.at('Address')
466
+ next if address.nil? || address.at('CountryCode').nil?
456
467
 
457
- city = address.get_text('City').to_s
458
- state = address.get_text('StateOrProvinceCode').to_s
459
- zip_code = address.get_text('PostalCode').to_s
460
- country = address.get_text('CountryCode').to_s
461
- next if country.blank?
468
+ city = address.at('City').try(:text)
469
+ state = address.at('StateOrProvinceCode').try(:text)
470
+ zip_code = address.at('PostalCode').try(:text)
471
+ country = address.at('CountryCode').try(:text)
462
472
 
463
473
  location = Location.new(:city => city, :state => state, :postal_code => zip_code, :country => country)
464
- description = event.get_text('EventDescription').to_s
474
+ description = event.at('EventDescription').text
465
475
 
466
- time = Time.parse("#{event.get_text('Timestamp').to_s}")
476
+ time = Time.parse(event.at('Timestamp').text)
467
477
  zoneless_time = time.utc
468
478
 
469
479
  shipment_events << ShipmentEvent.new(description, zoneless_time, location)
@@ -501,22 +511,17 @@ module ActiveShipping
501
511
  (Time.now + delay_in_hours.hours).to_date
502
512
  end
503
513
 
504
- def response_status_node(document)
505
- document.elements['/*/Notifications/']
506
- end
507
-
508
514
  def response_success?(document)
509
- response_node = response_status_node(document)
510
- return false if response_node.nil?
511
-
512
- %w(SUCCESS WARNING NOTE).include? response_node.get_text('Severity').to_s
515
+ highest_severity = document.root.at('HighestSeverity')
516
+ return false if highest_severity.nil?
517
+ %w(SUCCESS WARNING NOTE).include?(highest_severity.text)
513
518
  end
514
519
 
515
520
  def response_message(document)
516
- response_node = response_status_node(document)
517
- return "" if response_node.nil?
521
+ notifications = document.root.at('Notifications')
522
+ return "" if notifications.nil?
518
523
 
519
- "#{response_node.get_text('Severity')} - #{response_node.get_text('Code')}: #{response_node.get_text('Message')}"
524
+ "#{notifications.at('Severity').text} - #{notifications.at('Code').text}: #{notifications.at('Message').text}"
520
525
  end
521
526
 
522
527
  def commit(request, test = false)
@@ -535,15 +540,15 @@ module ActiveShipping
535
540
  def extract_address(document, possible_node_names)
536
541
  node = nil
537
542
  possible_node_names.each do |name|
538
- node ||= document.elements[name]
543
+ node = document.at(name)
539
544
  break if node
540
545
  end
541
546
 
542
- args = if node && node.elements['CountryCode']
547
+ args = if node && node.at('CountryCode')
543
548
  {
544
- :country => node.get_text('CountryCode').to_s,
545
- :province => node.get_text('StateOrProvinceCode').to_s,
546
- :city => node.get_text('City').to_s
549
+ :country => node.at('CountryCode').text,
550
+ :province => node.at('StateOrProvinceCode').text,
551
+ :city => node.at('City').text
547
552
  }
548
553
  else
549
554
  {
@@ -557,22 +562,23 @@ module ActiveShipping
557
562
  end
558
563
 
559
564
  def extract_timestamp(document, node_name)
560
- if timestamp_node = document.elements[node_name]
561
- Time.parse(timestamp_node.to_s).utc
565
+ if timestamp_node = document.at(node_name)
566
+ if timestamp_node.text =~ /\A(\d{4}-\d{2}-\d{2})T00:00:00\Z/
567
+ Date.parse($1)
568
+ else
569
+ Time.parse(timestamp_node.text)
570
+ end
562
571
  end
563
572
  end
564
573
 
565
- def remove_version_prefix(xml)
566
- if xml =~ /xmlns:v[0-9]/
567
- xml.gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>')
568
- else
569
- xml
574
+ def build_document(xml, expected_root_tag)
575
+ document = Nokogiri.XML(xml) { |config| config.strict }
576
+ document.remove_namespaces!
577
+ if document.root.nil? || document.root.name != expected_root_tag
578
+ raise ActiveShipping::ResponseContentError.new(StandardError.new('Invalid document'), xml)
570
579
  end
571
- end
572
-
573
- def build_document(xml)
574
- REXML::Document.new(xml)
575
- rescue REXML::ParseException => e
580
+ document
581
+ rescue Nokogiri::XML::SyntaxError => e
576
582
  raise ActiveShipping::ResponseContentError.new(e, xml)
577
583
  end
578
584
  end