active_shipping 1.0.0.pre4 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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