omniship 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. data/CHANGELOG +0 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.markdown +91 -0
  4. data/lib/omniship/address.rb +135 -0
  5. data/lib/omniship/base.rb +11 -0
  6. data/lib/omniship/carrier.rb +69 -0
  7. data/lib/omniship/carriers/fedex.rb +330 -0
  8. data/lib/omniship/carriers/ups.rb +564 -0
  9. data/lib/omniship/carriers/usps.rb +438 -0
  10. data/lib/omniship/carriers.rb +13 -0
  11. data/lib/omniship/contact.rb +19 -0
  12. data/lib/omniship/package.rb +147 -0
  13. data/lib/omniship/rate_estimate.rb +59 -0
  14. data/lib/omniship/rate_response.rb +16 -0
  15. data/lib/omniship/response.rb +42 -0
  16. data/lib/omniship/shipment_event.rb +12 -0
  17. data/lib/omniship/tracking_response.rb +18 -0
  18. data/lib/omniship/version.rb +3 -0
  19. data/lib/omniship.rb +73 -0
  20. data/lib/vendor/quantified/MIT-LICENSE +22 -0
  21. data/lib/vendor/quantified/README.markdown +49 -0
  22. data/lib/vendor/quantified/Rakefile +21 -0
  23. data/lib/vendor/quantified/init.rb +0 -0
  24. data/lib/vendor/quantified/lib/quantified/attribute.rb +208 -0
  25. data/lib/vendor/quantified/lib/quantified/length.rb +20 -0
  26. data/lib/vendor/quantified/lib/quantified/mass.rb +19 -0
  27. data/lib/vendor/quantified/lib/quantified.rb +6 -0
  28. data/lib/vendor/quantified/test/length_test.rb +92 -0
  29. data/lib/vendor/quantified/test/mass_test.rb +88 -0
  30. data/lib/vendor/quantified/test/test_helper.rb +10 -0
  31. data/lib/vendor/xml_node/README +36 -0
  32. data/lib/vendor/xml_node/Rakefile +21 -0
  33. data/lib/vendor/xml_node/benchmark/bench_generation.rb +32 -0
  34. data/lib/vendor/xml_node/init.rb +1 -0
  35. data/lib/vendor/xml_node/lib/xml_node.rb +222 -0
  36. data/lib/vendor/xml_node/test/test_generating.rb +94 -0
  37. data/lib/vendor/xml_node/test/test_parsing.rb +43 -0
  38. metadata +205 -0
data/CHANGELOG ADDED
File without changes
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Donavan White
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOa AND
17
+ NONINFRINGEMENT. IN NO EVENT SaALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.markdown ADDED
@@ -0,0 +1,91 @@
1
+ [![Code
2
+ Climate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/Digi-Cazter/omniship)
3
+
4
+ # Omniship
5
+
6
+ This gem is under active development, I'm only in the Alpha stage right now, so keep checking back for updates.
7
+
8
+ This library has been created to make web requests to common shipping carriers using XML. I created this to be easy to use with a nice Ruby API. This code was originally forked from the *Shopify/active_shipping* code, I began to strip it down cause I wan't a cleaner API along with the ability to actually create shipment labels with it. After changing enough code, I created this gem as its own project since it's different enough.
9
+
10
+ ## Supported Shipping Carriers
11
+
12
+ * [UPS](http://www.ups.com)
13
+ * [USPS](http://www.usps.com) COMING SOON!
14
+ * [FedEx](http://www.fedex.com) COMING SOON!
15
+
16
+ ## Simple example snippets
17
+ ### UPS Code Example ###
18
+ To run in test mode during development, pass :test => true as an option
19
+ into create_shipment and accept_shipment.
20
+
21
+ def create_shipment
22
+ # If you have created the omniship.yml config file
23
+ @config = OMNISHIP_CONFIG[Rails.env]['ups']
24
+ shipment = create_ups_shipment
25
+ end
26
+
27
+ def create_ups_shipment
28
+ # If using the yml config
29
+ ups = Omniship::UPS.new
30
+ # Else just pass in the credentials
31
+ ups = Omniship::UPS.new(:login => @user, :password => @password, :key => @key)
32
+ send_options = {}
33
+ send_options[:origin_account] = @config["account"] # Or just put the shipper account here
34
+ send_options[:service] = "03"
35
+ response = ups.create_shipment(origin, destination, package, options = send_options)
36
+ return ups.accept_shipment(response)
37
+ end
38
+
39
+ def origin
40
+ address = {}
41
+ address[:name] = "My House"
42
+ address[:address1] = "555 Diagonal"
43
+ address[:city] = "Saint George"
44
+ address[:state] = "UT"
45
+ address[:zip] = "84770"
46
+ address[:country] = "USA"
47
+ return Omniship::Address.new(address)
48
+ end
49
+
50
+ def destination
51
+ address = {}
52
+ address[:company_name] = "Wal-Mart"
53
+ address[:address1] = "555 Diagonal"
54
+ address[:city] = "Saint George"
55
+ address[:state] = "UT"
56
+ address[:zip] = "84770"
57
+ address[:country] = "USA"
58
+ return Omniship::Address.new(address)
59
+ end
60
+
61
+ def packages
62
+ # UPS can handle a single package or multiple packages
63
+ pkg_list = []
64
+ weight = 1
65
+ length = 1
66
+ width = 1
67
+ height = 1
68
+ package_type = "02"
69
+ pkg_list << Omniship::Package.new(weight.to_i,[length.to_i,width.to_i,height.to_i],:units => :imperial, :package_type => package_type)
70
+ return pkg_list
71
+ end
72
+
73
+ ## Tests
74
+
75
+ Currently this is on my TODO list. Check back for updates
76
+
77
+ ## Contributing
78
+
79
+ Before anyone starts contributing, I want to get a good stable version going and tests to follow, after I get that going then for the features you add, you should have both unit tests and remote tests. It's probably best to start with the remote tests, and then log those requests and responses and use them as the mocks for the unit tests.
80
+
81
+ To log requests and responses, just set the `logger` on your carrier class to some kind of `Logger` object:
82
+
83
+ Omniship::USPS.logger = Logger.new($stdout)
84
+
85
+ (This logging functionality is provided by the [`PostsData` module](https://github.com/Shopify/active_utils/blob/master/lib/active_utils/common/posts_data.rb) in the `active_utils` dependency.)
86
+
87
+ After you've pushed your well-tested changes to your github fork, make a pull request and we'll take it from there!
88
+
89
+ ## Legal Mumbo Jumbo
90
+
91
+ Unless otherwise noted in specific files, all code in the Omniship project is under the copyright and license described in the included MIT-LICENSE file.
@@ -0,0 +1,135 @@
1
+ module Omniship #:nodoc:
2
+ class Address
3
+ ADDRESS_TYPES = %w{residential commercial po_box}
4
+
5
+ attr_reader :options,
6
+ :country,
7
+ :postal_code,
8
+ :province,
9
+ :city,
10
+ :name,
11
+ :attention_name,
12
+ :address1,
13
+ :address2,
14
+ :address3,
15
+ :phone,
16
+ :fax,
17
+ :address_type,
18
+ :company_name
19
+
20
+ alias_method :zip, :postal_code
21
+ alias_method :postal, :postal_code
22
+ alias_method :state, :province
23
+ alias_method :territory, :province
24
+ alias_method :region, :province
25
+ alias_method :company, :company_name
26
+
27
+ def initialize(options = {})
28
+ @country = (options[:country].nil? or options[:country].is_a?(ActiveMerchant::Country)) ?
29
+ options[:country] :
30
+ ActiveMerchant::Country.find(options[:country])
31
+ @postal_code = options[:postal_code] || options[:postal] || options[:zip]
32
+ @province = options[:province] || options[:state] || options[:territory] || options[:region]
33
+ @city = options[:city]
34
+ @name = options[:name]
35
+ @address1 = options[:address1]
36
+ @address2 = options[:address2]
37
+ @address3 = options[:address3]
38
+ @phone = options[:phone]
39
+ @fax = options[:fax]
40
+ @company_name = options[:company_name] || options[:company]
41
+
42
+ self.address_type = options[:address_type]
43
+ end
44
+
45
+ def self.from(object, options={})
46
+ return object if object.is_a? Omniship::Address
47
+ attr_mappings = {
48
+ :name => [:name],
49
+ :country => [:country_code, :country],
50
+ :postal_code => [:postal_code, :zip, :postal],
51
+ :province => [:province_code, :state_code, :territory_code, :region_code, :province, :state, :territory, :region],
52
+ :city => [:city, :town],
53
+ :address1 => [:address1, :address, :street],
54
+ :address2 => [:address2],
55
+ :address3 => [:address3],
56
+ :phone => [:phone, :phone_number],
57
+ :fax => [:fax, :fax_number],
58
+ :address_type => [:address_type],
59
+ :company_name => [:company, :company_name]
60
+ }
61
+ attributes = {}
62
+ hash_access = begin
63
+ object[:some_symbol]
64
+ true
65
+ rescue
66
+ false
67
+ end
68
+ attr_mappings.each do |pair|
69
+ pair[1].each do |sym|
70
+ if value = (object[sym] if hash_access) || (object.send(sym) if object.respond_to?(sym) && (!hash_access || !Hash.public_instance_methods.include?(sym.to_s)))
71
+ attributes[pair[0]] = value
72
+ break
73
+ end
74
+ end
75
+ end
76
+ attributes.delete(:address_type) unless ADDRESS_TYPES.include?(attributes[:address_type].to_s)
77
+ self.new(attributes.update(options))
78
+ end
79
+
80
+ def country_code(format = :alpha2)
81
+ @country.nil? ? nil : @country.code(format).value
82
+ end
83
+
84
+ def residential?; @address_type == 'residential' end
85
+ def commercial?; @address_type == 'commercial' end
86
+ def po_box?; @address_type == 'po_box' end
87
+
88
+ def address_type=(value)
89
+ return unless value.present?
90
+ raise ArgumentError.new("address_type must be one of #{ADDRESS_TYPES.join(', ')}") unless ADDRESS_TYPES.include?(value.to_s)
91
+ @address_type = value.to_s
92
+ end
93
+
94
+ def to_hash
95
+ {
96
+ :country => country_code,
97
+ :postal_code => postal_code,
98
+ :province => province,
99
+ :city => city,
100
+ :name => name,
101
+ :address1 => address1,
102
+ :address2 => address2,
103
+ :address3 => address3,
104
+ :phone => phone,
105
+ :fax => fax,
106
+ :address_type => address_type,
107
+ :company_name => company_name
108
+ }
109
+ end
110
+
111
+ def to_xml(options={})
112
+ options[:root] ||= "address"
113
+ to_hash.to_xml(options)
114
+ end
115
+
116
+ def to_s
117
+ prettyprint.gsub(/\n/, ' ')
118
+ end
119
+
120
+ def prettyprint
121
+ chunks = []
122
+ chunks << [@name, @address1,@address2,@address3].reject {|e| e.blank?}.join("\n")
123
+ chunks << [@city,@province,@postal_code].reject {|e| e.blank?}.join(', ')
124
+ chunks << @country
125
+ chunks.reject {|e| e.blank?}.join("\n")
126
+ end
127
+
128
+ def inspect
129
+ string = prettyprint
130
+ string << "\nPhone: #{@phone}" unless @phone.blank?
131
+ string << "\nFax: #{@fax}" unless @fax.blank?
132
+ string
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,11 @@
1
+ module Omniship
2
+ module Base
3
+ mattr_accessor :mode
4
+ self.mode = :production
5
+
6
+ def self.carrier(name)
7
+ Omniship::Carriers.all.find {|c| c.name.downcase == name.to_s.downcase} ||
8
+ raise(NameError, "unknown carrier #{name}")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,69 @@
1
+ module Omniship
2
+ class Carrier
3
+
4
+ include ActiveMerchant::RequiresParameters
5
+ include ActiveMerchant::PostsData
6
+ include Quantified
7
+
8
+ attr_reader :last_request
9
+ attr_accessor :test_mode
10
+ alias_method :test_mode?, :test_mode
11
+
12
+ # Credentials should be in options hash under keys :login, :password and/or :key.
13
+ def initialize(options = {})
14
+ #requirements.each {|key| requires!(options, key)}
15
+ @options = options
16
+ @last_request = nil
17
+ @test_mode = @options[:test]
18
+ @config = Omniship.setup
19
+ end
20
+
21
+ # Override to return required keys in options hash for initialize method.
22
+ def requirements
23
+ []
24
+ end
25
+
26
+ # Override with whatever you need to get the rates
27
+ def find_rates(origin, destination, packages, options = {})
28
+ end
29
+
30
+ # Validate credentials with a call to the API. By default this just does a find_rates call
31
+ # with the orgin and destination both as the carrier's default_location. Override to provide
32
+ # alternate functionality, such as checking for test_mode to use test servers, etc.
33
+ def valid_credentials?
34
+ location = self.class.default_location
35
+ find_rates(location,location,Package.new(100, [5,15,30]), :test => test_mode)
36
+ rescue Omniship::ResponseError
37
+ false
38
+ else
39
+ true
40
+ end
41
+
42
+ def maximum_weight
43
+ Mass.new(150, :pounds)
44
+ end
45
+
46
+ protected
47
+
48
+ def node_text_or_nil(xml_node)
49
+ xml_node ? xml_node.text : nil
50
+ end
51
+
52
+ # Override in subclasses for non-U.S.-based carriers.
53
+ def self.default_location
54
+ Address.new( :country => 'US',
55
+ :state => 'CA',
56
+ :city => 'Beverly Hills',
57
+ :address1 => '455 N. Rexford Dr.',
58
+ :address2 => '3rd Floor',
59
+ :zip => '90210',
60
+ :phone => '1-310-285-1013',
61
+ :fax => '1-310-275-8159')
62
+ end
63
+
64
+ # Use after building the request to save for later inspection. Probably won't ever be overridden.
65
+ def save_request(r)
66
+ @last_request = r
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,330 @@
1
+ # FedEx module by Jimmy Baker
2
+ # http://github.com/jimmyebaker
3
+
4
+ module Omniship
5
+ # :key is your developer API key
6
+ # :password is your API password
7
+ # :account is your FedEx account number
8
+ # :login is your meter number
9
+ class FedEx < Carrier
10
+ self.retry_safe = true
11
+
12
+ cattr_reader :name
13
+ @@name = "FedEx"
14
+
15
+ TEST_URL = 'https://gatewaybeta.fedex.com:443/xml'
16
+ LIVE_URL = 'https://gateway.fedex.com:443/xml'
17
+
18
+ CarrierCodes = {
19
+ "fedex_ground" => "FDXG",
20
+ "fedex_express" => "FDXE"
21
+ }
22
+
23
+ ServiceTypes = {
24
+ "PRIORITY_OVERNIGHT" => "FedEx Priority Overnight",
25
+ "PRIORITY_OVERNIGHT_SATURDAY_DELIVERY" => "FedEx Priority Overnight Saturday Delivery",
26
+ "FEDEX_2_DAY" => "FedEx 2 Day",
27
+ "FEDEX_2_DAY_SATURDAY_DELIVERY" => "FedEx 2 Day Saturday Delivery",
28
+ "STANDARD_OVERNIGHT" => "FedEx Standard Overnight",
29
+ "FIRST_OVERNIGHT" => "FedEx First Overnight",
30
+ "FIRST_OVERNIGHT_SATURDAY_DELIVERY" => "FedEx First Overnight Saturday Delivery",
31
+ "FEDEX_EXPRESS_SAVER" => "FedEx Express Saver",
32
+ "FEDEX_1_DAY_FREIGHT" => "FedEx 1 Day Freight",
33
+ "FEDEX_1_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 1 Day Freight Saturday Delivery",
34
+ "FEDEX_2_DAY_FREIGHT" => "FedEx 2 Day Freight",
35
+ "FEDEX_2_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 2 Day Freight Saturday Delivery",
36
+ "FEDEX_3_DAY_FREIGHT" => "FedEx 3 Day Freight",
37
+ "FEDEX_3_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 3 Day Freight Saturday Delivery",
38
+ "INTERNATIONAL_PRIORITY" => "FedEx International Priority",
39
+ "INTERNATIONAL_PRIORITY_SATURDAY_DELIVERY" => "FedEx International Priority Saturday Delivery",
40
+ "INTERNATIONAL_ECONOMY" => "FedEx International Economy",
41
+ "INTERNATIONAL_FIRST" => "FedEx International First",
42
+ "INTERNATIONAL_PRIORITY_FREIGHT" => "FedEx International Priority Freight",
43
+ "INTERNATIONAL_ECONOMY_FREIGHT" => "FedEx International Economy Freight",
44
+ "GROUND_HOME_DELIVERY" => "FedEx Ground Home Delivery",
45
+ "FEDEX_GROUND" => "FedEx Ground",
46
+ "INTERNATIONAL_GROUND" => "FedEx International Ground"
47
+ }
48
+
49
+ PackageTypes = {
50
+ "fedex_envelope" => "FEDEX_ENVELOPE",
51
+ "fedex_pak" => "FEDEX_PAK",
52
+ "fedex_box" => "FEDEX_BOX",
53
+ "fedex_tube" => "FEDEX_TUBE",
54
+ "fedex_10_kg_box" => "FEDEX_10KG_BOX",
55
+ "fedex_25_kg_box" => "FEDEX_25KG_BOX",
56
+ "your_packaging" => "YOUR_PACKAGING"
57
+ }
58
+
59
+ DropoffTypes = {
60
+ 'regular_pickup' => 'REGULAR_PICKUP',
61
+ 'request_courier' => 'REQUEST_COURIER',
62
+ 'dropbox' => 'DROP_BOX',
63
+ 'business_service_center' => 'BUSINESS_SERVICE_CENTER',
64
+ 'station' => 'STATION'
65
+ }
66
+
67
+ PaymentTypes = {
68
+ 'sender' => 'SENDER',
69
+ 'recipient' => 'RECIPIENT',
70
+ 'third_party' => 'THIRDPARTY',
71
+ 'collect' => 'COLLECT'
72
+ }
73
+
74
+ PackageIdentifierTypes = {
75
+ 'tracking_number' => 'TRACKING_NUMBER_OR_DOORTAG',
76
+ 'door_tag' => 'TRACKING_NUMBER_OR_DOORTAG',
77
+ 'rma' => 'RMA',
78
+ 'ground_shipment_id' => 'GROUND_SHIPMENT_ID',
79
+ 'ground_invoice_number' => 'GROUND_INVOICE_NUMBER',
80
+ 'ground_customer_reference' => 'GROUND_CUSTOMER_REFERENCE',
81
+ 'ground_po' => 'GROUND_PO',
82
+ 'express_reference' => 'EXPRESS_REFERENCE',
83
+ 'express_mps_master' => 'EXPRESS_MPS_MASTER'
84
+ }
85
+
86
+ def self.service_name_for_code(service_code)
87
+ ServiceTypes[service_code] || begin
88
+ name = service_code.downcase.split('_').collect{|word| word.capitalize }.join(' ')
89
+ "FedEx #{name.sub(/Fedex /, '')}"
90
+ end
91
+ end
92
+
93
+ def requirements
94
+ [:key, :password, :account, :login]
95
+ end
96
+
97
+ def find_rates(origin, destination, packages, options = {})
98
+ options = @options.update(options)
99
+ packages = Array(packages)
100
+
101
+ rate_request = build_rate_request(origin, destination, packages, options)
102
+
103
+ response = commit(save_request(rate_request), (options[:test] || false)).gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>')
104
+
105
+ parse_rate_response(origin, destination, packages, response, options)
106
+ end
107
+
108
+ def find_tracking_info(tracking_number, options={})
109
+ options = @options.update(options)
110
+
111
+ tracking_request = build_tracking_request(tracking_number, options)
112
+ response = commit(save_request(tracking_request), (options[:test] || false)).gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>')
113
+ parse_tracking_response(response, options)
114
+ end
115
+
116
+ protected
117
+ def build_rate_request(origin, destination, packages, options={})
118
+ imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
119
+
120
+ xml_request = XmlNode.new('RateRequest', 'xmlns' => 'http://fedex.com/ws/rate/v6') do |root_node|
121
+ root_node << build_request_header
122
+
123
+ # Version
124
+ root_node << XmlNode.new('Version') do |version_node|
125
+ version_node << XmlNode.new('ServiceId', 'crs')
126
+ version_node << XmlNode.new('Major', '6')
127
+ version_node << XmlNode.new('Intermediate', '0')
128
+ version_node << XmlNode.new('Minor', '0')
129
+ end
130
+
131
+ # Returns delivery dates
132
+ root_node << XmlNode.new('ReturnTransitAndCommit', true)
133
+ # Returns saturday delivery shipping options when available
134
+ root_node << XmlNode.new('VariableOptions', 'SATURDAY_DELIVERY')
135
+
136
+ root_node << XmlNode.new('RequestedShipment') do |rs|
137
+ rs << XmlNode.new('ShipTimestamp', Time.now)
138
+ rs << XmlNode.new('DropoffType', options[:dropoff_type] || 'REGULAR_PICKUP')
139
+ rs << XmlNode.new('PackagingType', options[:packaging_type] || 'YOUR_PACKAGING')
140
+
141
+ rs << build_location_node('Shipper', (options[:shipper] || origin))
142
+ rs << build_location_node('Recipient', destination)
143
+ if options[:shipper] and options[:shipper] != origin
144
+ rs << build_location_node('Origin', origin)
145
+ end
146
+
147
+ rs << XmlNode.new('RateRequestTypes', 'ACCOUNT')
148
+ rs << XmlNode.new('PackageCount', packages.size)
149
+ packages.each do |pkg|
150
+ rs << XmlNode.new('RequestedPackages') do |rps|
151
+ rps << XmlNode.new('Weight') do |tw|
152
+ tw << XmlNode.new('Units', imperial ? 'LB' : 'KG')
153
+ tw << XmlNode.new('Value', [((imperial ? pkg.lbs : pkg.kgs).to_f*1000).round/1000.0, 0.1].max)
154
+ end
155
+ rps << XmlNode.new('Dimensions') do |dimensions|
156
+ [:length,:width,:height].each do |axis|
157
+ value = ((imperial ? pkg.inches(axis) : pkg.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
158
+ dimensions << XmlNode.new(axis.to_s.capitalize, value.ceil)
159
+ end
160
+ dimensions << XmlNode.new('Units', imperial ? 'IN' : 'CM')
161
+ end
162
+ end
163
+ end
164
+
165
+ end
166
+ end
167
+ xml_request.to_s
168
+ end
169
+
170
+ def build_tracking_request(tracking_number, options={})
171
+ xml_request = XmlNode.new('TrackRequest', 'xmlns' => 'http://fedex.com/ws/track/v3') do |root_node|
172
+ root_node << build_request_header
173
+
174
+ # Version
175
+ root_node << XmlNode.new('Version') do |version_node|
176
+ version_node << XmlNode.new('ServiceId', 'trck')
177
+ version_node << XmlNode.new('Major', '3')
178
+ version_node << XmlNode.new('Intermediate', '0')
179
+ version_node << XmlNode.new('Minor', '0')
180
+ end
181
+
182
+ root_node << XmlNode.new('PackageIdentifier') do |package_node|
183
+ package_node << XmlNode.new('Value', tracking_number)
184
+ package_node << XmlNode.new('Type', PackageIdentifierTypes[options['package_identifier_type'] || 'tracking_number'])
185
+ end
186
+
187
+ root_node << XmlNode.new('ShipDateRangeBegin', options['ship_date_range_begin']) if options['ship_date_range_begin']
188
+ root_node << XmlNode.new('ShipDateRangeEnd', options['ship_date_range_end']) if options['ship_date_range_end']
189
+ root_node << XmlNode.new('IncludeDetailedScans', 1)
190
+ end
191
+ xml_request.to_s
192
+ end
193
+
194
+ def build_request_header
195
+ web_authentication_detail = XmlNode.new('WebAuthenticationDetail') do |wad|
196
+ wad << XmlNode.new('UserCredential') do |uc|
197
+ uc << XmlNode.new('Key', @options[:key])
198
+ uc << XmlNode.new('Password', @options[:password])
199
+ end
200
+ end
201
+
202
+ client_detail = XmlNode.new('ClientDetail') do |cd|
203
+ cd << XmlNode.new('AccountNumber', @options[:account])
204
+ cd << XmlNode.new('MeterNumber', @options[:login])
205
+ end
206
+
207
+ trasaction_detail = XmlNode.new('TransactionDetail') do |td|
208
+ td << XmlNode.new('CustomerTransactionId', 'ActiveShipping') # TODO: Need to do something better with this..
209
+ end
210
+
211
+ [web_authentication_detail, client_detail, trasaction_detail]
212
+ end
213
+
214
+ def build_location_node(name, location)
215
+ location_node = XmlNode.new(name) do |xml_node|
216
+ xml_node << XmlNode.new('Address') do |address_node|
217
+ address_node << XmlNode.new('PostalCode', location.postal_code)
218
+ address_node << XmlNode.new("CountryCode", location.country_code(:alpha2))
219
+
220
+ address_node << XmlNode.new("Residential", true) unless location.commercial?
221
+ end
222
+ end
223
+ end
224
+
225
+ def parse_rate_response(origin, destination, packages, response, options)
226
+ rate_estimates = []
227
+ success, message = nil
228
+
229
+ xml = REXML::Document.new(response)
230
+ root_node = xml.elements['RateReply']
231
+
232
+ success = response_success?(xml)
233
+ message = response_message(xml)
234
+
235
+ root_node.elements.each('RateReplyDetails') do |rated_shipment|
236
+ service_code = rated_shipment.get_text('ServiceType').to_s
237
+ is_saturday_delivery = rated_shipment.get_text('AppliedOptions').to_s == 'SATURDAY_DELIVERY'
238
+ service_type = is_saturday_delivery ? "#{service_code}_SATURDAY_DELIVERY" : service_code
239
+
240
+ currency = handle_uk_currency(rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').to_s)
241
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
242
+ self.class.service_name_for_code(service_type),
243
+ :service_code => service_code,
244
+ :total_price => rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').to_s.to_f,
245
+ :currency => currency,
246
+ :packages => packages,
247
+ :delivery_range => [rated_shipment.get_text('DeliveryTimestamp').to_s] * 2)
248
+ end
249
+
250
+ if rate_estimates.empty?
251
+ success = false
252
+ message = "No shipping rates could be found for the destination address" if message.blank?
253
+ end
254
+
255
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request, :log_xml => options[:log_xml])
256
+ end
257
+
258
+ def parse_tracking_response(response, options)
259
+ xml = REXML::Document.new(response)
260
+ root_node = xml.elements['TrackReply']
261
+
262
+ success = response_success?(xml)
263
+ message = response_message(xml)
264
+
265
+ if success
266
+ tracking_number, origin, destination = nil
267
+ shipment_events = []
268
+
269
+ tracking_details = root_node.elements['TrackDetails']
270
+ tracking_number = tracking_details.get_text('TrackingNumber').to_s
271
+
272
+ destination_node = tracking_details.elements['DestinationAddress']
273
+ destination = Address.new(
274
+ :country => destination_node.get_text('CountryCode').to_s,
275
+ :province => destination_node.get_text('StateOrProvinceCode').to_s,
276
+ :city => destination_node.get_text('City').to_s
277
+ )
278
+
279
+ tracking_details.elements.each('Events') do |event|
280
+ address = event.elements['Address']
281
+
282
+ city = address.get_text('City').to_s
283
+ state = address.get_text('StateOrProvinceCode').to_s
284
+ zip_code = address.get_text('PostalCode').to_s
285
+ country = address.get_text('CountryCode').to_s
286
+ next if country.blank?
287
+
288
+ location = Address.new(:city => city, :state => state, :postal_code => zip_code, :country => country)
289
+ description = event.get_text('EventDescription').to_s
290
+
291
+ # for now, just assume UTC, even though it probably isn't
292
+ time = Time.parse("#{event.get_text('Timestamp').to_s}")
293
+ zoneless_time = Time.utc(time.year, time.month, time.mday, time.hour, time.min, time.sec)
294
+
295
+ shipment_events << ShipmentEvent.new(description, zoneless_time, location)
296
+ end
297
+ shipment_events = shipment_events.sort_by(&:time)
298
+ end
299
+
300
+ TrackingResponse.new(success, message, Hash.from_xml(response),
301
+ :xml => response,
302
+ :request => last_request,
303
+ :shipment_events => shipment_events,
304
+ :destination => destination,
305
+ :tracking_number => tracking_number
306
+ )
307
+ end
308
+
309
+ def response_status_node(document)
310
+ document.elements['/*/Notifications/']
311
+ end
312
+
313
+ def response_success?(document)
314
+ %w{SUCCESS WARNING NOTE}.include? response_status_node(document).get_text('Severity').to_s
315
+ end
316
+
317
+ def response_message(document)
318
+ response_node = response_status_node(document)
319
+ "#{response_status_node(document).get_text('Severity').to_s} - #{response_node.get_text('Code').to_s}: #{response_node.get_text('Message').to_s}"
320
+ end
321
+
322
+ def commit(request, test = false)
323
+ ssl_post(test ? TEST_URL : LIVE_URL, request.gsub("\n",''))
324
+ end
325
+
326
+ def handle_uk_currency(currency)
327
+ currency =~ /UKL/i ? 'GBP' : currency
328
+ end
329
+ end
330
+ end