active_shipping 0.9.13 → 0.9.14

Sign up to get free protection for your applications and to get access to all the features.
data/README.markdown CHANGED
@@ -1,14 +1,11 @@
1
1
  # Active Shipping
2
2
 
3
- This library is meant to interface with the web services of various shipping carriers. The goal is to abstract the features that are most frequently used into a pleasant and consistent Ruby API. Active Shipping is an extension of [Active Merchant][], and as such, it borrows heavily from conventions used in the latter.
3
+ This library interfaces with the web services of various shipping carriers. The goal is to abstract the features that are most frequently used into a pleasant and consistent Ruby API. Active Shipping is an extension of [Active Merchant][], and as such, it borrows heavily from conventions used in the latter.
4
4
 
5
- We are starting out by only implementing the ability to list available shipping rates for a particular origin, destination, and set of packages. Further development could take advantage of other common features of carriers' web services such as tracking orders and printing labels.
6
-
7
- Active Shipping is currently being used and improved in a production environment for the e-commerce application [Shopify][]. Development is being done by [James MacAulay][] (<james@jadedpixel.com>). Discussion is welcome in the [Active Merchant Google Group][discuss].
5
+ Active Shipping is currently being used and improved in a production environment for [Shopify][]. Development is being done by the Shopify integrations team (<integrations-team@shopify.com>). Discussion is welcome in the [Active Merchant Google Group][discuss].
8
6
 
9
7
  [Active Merchant]:http://www.activemerchant.org
10
8
  [Shopify]:http://www.shopify.com
11
- [James MacAulay]:http://jmacaulay.net
12
9
  [discuss]:http://groups.google.com/group/activemerchant
13
10
 
14
11
  ## Supported Shipping Carriers
@@ -20,28 +17,11 @@ Active Shipping is currently being used and improved in a production environment
20
17
  * [New Zealand Post](http://www.nzpost.co.nz)
21
18
  * more soon!
22
19
 
23
- ## Prerequisites
24
-
25
- * [active_support](http://github.com/rails/rails/tree/master/activesupport)
26
- * [xml_node](http://github.com/tobi/xml_node/) (right now a version of it is actually included in this library, so you don't need to worry about it yet)
27
- * [mocha](http://mocha.rubyforge.org/) for the tests
28
-
29
- ## Download & Installation
30
-
31
- Currently this library is available on GitHub:
32
-
33
- <http://github.com/Shopify/active_shipping>
34
-
35
- You will need to get [Git][] if you don't have it. Then:
20
+ ## Installation
36
21
 
37
- > git clone git://github.com/Shopify/active_shipping.git
22
+ gem install active_shipping
38
23
 
39
- (That URL is case-sensitive, so watch out.)
40
-
41
- Active Shipping includes an init.rb file. This means that Rails will automatically load it on startup. Check out [git-archive][] for exporting the file tree from your repository to your vendor directory.
42
-
43
- [Git]:http://git.or.cz/
44
- [git-archive]:http://www.kernel.org/pub/software/scm/git/docs/git-archive.html
24
+ ...or add it to your [Gemfile](http://gembundler.com/).
45
25
 
46
26
  ## Sample Usage
47
27
 
@@ -111,23 +91,27 @@ Active Shipping includes an init.rb file. This means that Rails will automatical
111
91
  # Scanned at FedEx sort facility at KNOXVILLE, TN on Fri Oct 24 05:56:00 UTC 2008.
112
92
  # Delivered at Knoxville, TN on Fri Oct 24 16:45:00 UTC 2008. Signed for by: T.BAKER
113
93
 
114
- ## TODO
94
+ ## Running the tests
115
95
 
116
- * proper documentation
117
- * carrier code template generator
118
- * more carriers
119
- * support more features for existing carriers
120
- * bin-packing algorithm (preferably implemented in ruby)
121
- * order tracking
122
- * label printing
96
+ After installing dependencies with `bundle install`, you can run the unit tests with `rake test:units` and the remote tests with `rake test:remote`. The unit tests mock out requests and responses so that everything runs locally, while the remote tests actually hit the carrier servers. For the remote tests, you'll need valid test credentials for any carriers' tests you want to run. The credentials should go in ~/.active_merchant/fixtures.yml, and the format of that file can be seen in the included [fixtures.yml](https://github.com/Shopify/active_shipping/blob/master/test/fixtures.yml).
123
97
 
124
98
  ## Contributing
125
99
 
126
100
  Yes, please! Take a look at the tests and the implementation of the Carrier class to see how the basics work. At some point soon there will be a carrier template generator along the lines of the gateway generator included in Active Merchant, but carrier.rb outlines most of what's necessary. The other main classes that would be good to familiarize yourself with are Location, Package, and Response.
127
101
 
128
- The nicest way to submit changes would be to set up a GitHub account and fork this project, then initiate a pull request when you want your changes looked at. You can also make a patch (preferably with [git-diff][]) and email to james@jadedpixel.com.
102
+ 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. You can see how this works with the USPS tests right now:
103
+
104
+ https://github.com/Shopify/active_shipping/blob/master/test/remote/usps_test.rb
105
+ https://github.com/Shopify/active_shipping/blob/master/test/unit/carriers/usps_test.rb
106
+ https://github.com/Shopify/active_shipping/tree/master/test/fixtures/xml/usps
107
+
108
+ To log requests and responses, just set the `logger` on your carrier class to some kind of `Logger` object:
109
+
110
+ USPS.logger = Logger.new($stdout)
111
+
112
+ (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.)
129
113
 
130
- [git-diff]:http://www.kernel.org/pub/software/scm/git/docs/git-diff.html
114
+ After you've pushed your well-tested changes to your github fork, make a pull request and we'll take it from there!
131
115
 
132
116
  ## Contributors
133
117
 
@@ -139,4 +123,4 @@ The nicest way to submit changes would be to set up a GitHub account and fork th
139
123
 
140
124
  ## Legal Mumbo Jumbo
141
125
 
142
- Unless otherwise noted in specific files, all code in the Active Shipping project is under the copyright and license described in the included MIT-LICENSE file.
126
+ Unless otherwise noted in specific files, all code in the Active Shipping project is under the copyright and license described in the included MIT-LICENSE file.
@@ -35,7 +35,7 @@ autoload :XmlNode, 'vendor/xml_node/lib/xml_node'
35
35
  autoload :Quantified, 'vendor/quantified/lib/quantified'
36
36
 
37
37
  require 'net/https'
38
- require 'active_merchant/common'
38
+ require 'active_utils'
39
39
 
40
40
  require 'active_shipping/shipping/base'
41
41
  require 'active_shipping/shipping/response'
@@ -45,5 +45,6 @@ require 'active_shipping/shipping/package'
45
45
  require 'active_shipping/shipping/location'
46
46
  require 'active_shipping/shipping/rate_estimate'
47
47
  require 'active_shipping/shipping/shipment_event'
48
+ require 'active_shipping/shipping/shipment_packer'
48
49
  require 'active_shipping/shipping/carrier'
49
50
  require 'active_shipping/shipping/carriers'
@@ -153,15 +153,14 @@ module ActiveMerchant
153
153
  boxes = []
154
154
  if success
155
155
  xml.elements.each('eparcel/ratesAndServicesResponse/product') do |product|
156
- service_name = (@options[:french] ? @@name_french : @@name) + product.get_text('name').to_s
156
+ service_name = (@options[:french] ? @@name_french : @@name) + " " + product.get_text('name').to_s
157
157
  service_code = product.attribute('id').to_s
158
- delivery_date = date_for(product.get_text('deliveryDate').to_s)
159
158
 
160
159
  rate_estimates << RateEstimate.new(origin, destination, @@name, service_name,
161
160
  :service_code => service_code,
162
161
  :total_price => product.get_text('rate').to_s,
163
- :delivery_date => delivery_date,
164
- :currency => 'CAD'
162
+ :currency => 'CAD',
163
+ :delivery_range => [product.get_text('deliveryDate').to_s] * 2
165
164
  )
166
165
  end
167
166
 
@@ -209,12 +208,6 @@ module ActiveMerchant
209
208
 
210
209
  CanadaPostRateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :boxes => boxes, :postal_outlets => postal_outlets)
211
210
  end
212
-
213
- def date_for(string)
214
- string && Time.parse(string)
215
- rescue ArgumentError
216
- nil
217
- end
218
211
 
219
212
  def response_success?(xml)
220
213
  value = xml.get_text('eparcel/ratesAndServicesResponse/statusCode').to_s
@@ -265,4 +258,4 @@ module ActiveMerchant
265
258
  end
266
259
  end
267
260
  end
268
- end
261
+ end
@@ -86,10 +86,7 @@ module ActiveMerchant
86
86
  }
87
87
 
88
88
  def self.service_name_for_code(service_code)
89
- ServiceTypes[service_code] || begin
90
- name = service_code.downcase.split('_').collect{|word| word.capitalize }.join(' ')
91
- "FedEx #{name.sub(/Fedex /, '')}"
92
- end
89
+ ServiceTypes[service_code] || "FedEx #{service_code.titleize.sub(/Fedex /, '')}"
93
90
  end
94
91
 
95
92
  def requirements
@@ -218,6 +215,8 @@ module ActiveMerchant
218
215
  xml_node << XmlNode.new('Address') do |address_node|
219
216
  address_node << XmlNode.new('PostalCode', location.postal_code)
220
217
  address_node << XmlNode.new("CountryCode", location.country_code(:alpha2))
218
+
219
+ address_node << XmlNode.new("Residential", true) unless location.commercial?
221
220
  end
222
221
  end
223
222
  end
@@ -244,7 +243,7 @@ module ActiveMerchant
244
243
  :total_price => rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').to_s.to_f,
245
244
  :currency => currency,
246
245
  :packages => packages,
247
- :delivery_date => rated_shipment.get_text('DeliveryTimestamp').to_s)
246
+ :delivery_range => [rated_shipment.get_text('DeliveryTimestamp').to_s] * 2)
248
247
  end
249
248
 
250
249
  if rate_estimates.empty?
@@ -316,7 +315,7 @@ module ActiveMerchant
316
315
 
317
316
  def response_message(document)
318
317
  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}"
318
+ "#{response_status_node(document).get_text('Severity')} - #{response_node.get_text('Code')}: #{response_node.get_text('Message')}"
320
319
  end
321
320
 
322
321
  def commit(request, test = false)
@@ -2,138 +2,268 @@ module ActiveMerchant
2
2
  module Shipping
3
3
  class NewZealandPost < Carrier
4
4
 
5
- # class NewZealandPostRateResponse < RateResponse
6
- # end
7
-
8
5
  cattr_reader :name
9
6
  @@name = "New Zealand Post"
10
7
 
11
- URL = "http://workshop.nzpost.co.nz/api/v1/rate.xml"
8
+ URL = "http://api.nzpost.co.nz/ratefinder"
12
9
 
13
- # Override to return required keys in options hash for initialize method.
14
10
  def requirements
15
11
  [:key]
16
12
  end
17
13
 
18
- # Override with whatever you need to get the rates
19
14
  def find_rates(origin, destination, packages, options = {})
20
- packages = Array(packages)
21
- rate_responses = []
22
- packages.each do |package|
23
- if package.tube?
24
- request_hash = build_tube_request_params(origin, destination, package, options)
25
- else
26
- request_hash = build_rectangular_request_params(origin, destination, package, options)
27
- end
28
- url = URL + '?' + request_hash.to_param
29
- response = ssl_get(url)
30
- rate_responses << parse_rate_response(origin, destination, package, response, options)
31
- end
32
- combine_rate_responses(rate_responses, packages)
33
- end
34
-
35
- def maximum_weight
36
- Mass.new(20, :kilograms)
15
+ options = @options.merge(options)
16
+ request = RateRequest.from(origin, destination, packages, options)
17
+ request.raw_responses = commit(request.urls) if request.new_zealand_origin?
18
+ request.rate_response
37
19
  end
38
20
 
39
21
  protected
40
22
 
41
- # Override in subclasses for non-U.S.-based carriers.
42
- def self.default_location
43
- Location.new(:postal_code => '6011')
23
+ def commit(urls)
24
+ save_request(urls).map { |url| ssl_get(url) }
44
25
  end
45
26
 
46
- private
47
-
48
- def build_rectangular_request_params(origin, destination, package, options = {})
49
- params = {
50
- :postcode_src => origin.postal_code,
51
- :postcode_dest => destination.postal_code,
52
- :api_key => @options[:key],
53
- :height => "#{package.centimetres(:height) * 10}",
54
- :thickness => "#{package.centimetres(:width) * 10}",
55
- :length => "#{package.centimetres(:length) * 10}",
56
- :weight =>"%.1f" % (package.weight.amount / 1000.0)
57
- }
27
+ def self.default_location
28
+ Location.new({
29
+ :country => "NZ",
30
+ :city => "Wellington",
31
+ :address1 => "22 Waterloo Quay",
32
+ :address2 => "Pipitea",
33
+ :postal_code => "6011"
34
+ })
58
35
  end
59
36
 
60
- def build_tube_request_params(origin, destination, package, options = {})
61
- params = {
62
- :postcode_src => origin.postal_code,
63
- :postcode_dest => destination.postal_code,
64
- :api_key => @options[:key],
65
- :diameter => "#{package.centimetres(:width) * 10}",
66
- :length => "#{package.centimetres(:length) * 10}",
67
- :weight => "%.1f" % (package.weight.amount / 1000.0)
68
- }
37
+ class NewZealandPostRateResponse < RateResponse
38
+
39
+ attr_reader :raw_responses
40
+
41
+ def initialize(success, message, params = {}, options = {})
42
+ @raw_responses = options[:raw_responses]
43
+ super
44
+ end
69
45
  end
70
46
 
71
- def parse_rate_response(origin, destination, package, response, options={})
72
- xml = REXML::Document.new(response)
73
- if response_success?(xml)
74
- rate_estimates = []
75
- xml.elements.each('hash/products/product') do |prod|
76
- if( prod.get_text('packaging') == 'postage_only' )
77
- rate_estimates << RateEstimate.new(origin,
78
- destination,
79
- @@name,
80
- prod.get_text('service-group-description').to_s,
81
- :total_price => prod.get_text('cost').to_s.to_f,
82
- :currency => 'NZD',
83
- :service_code => prod.get_text('code').to_s,
84
- :package => package)
85
- end
47
+ class RateRequest
48
+
49
+ attr_reader :urls
50
+ attr_writer :raw_responses
51
+
52
+ def self.from(*args)
53
+ return International.new(*args) unless domestic?(args[0..1])
54
+ Domestic.new(*args)
55
+ end
56
+
57
+ def initialize(origin, destination, packages, options)
58
+ @origin = Location.from(origin)
59
+ @destination = Location.from(destination)
60
+ @packages = Array(packages).map { |package| NewZealandPostPackage.new(package, api) }
61
+ @params = { :format => "json", :api_key => options[:key] }
62
+ @test = options[:test]
63
+ @rates = @responses = @raw_responses = []
64
+ @urls = @packages.map { |package| url(package) }
65
+ end
66
+
67
+ def rate_response
68
+ @rates = rates
69
+ NewZealandPostRateResponse.new(true, "success", response_params, response_options)
70
+ rescue => error
71
+ NewZealandPostRateResponse.new(false, error.message, response_params, response_options)
72
+ end
73
+
74
+ def new_zealand_origin?
75
+ self.class.new_zealand?(@origin)
76
+ end
77
+
78
+ protected
79
+
80
+ def self.new_zealand?(location)
81
+ [ 'NZ', nil ].include?(Location.from(location).country_code)
82
+ end
83
+
84
+ def self.domestic?(locations)
85
+ locations.select { |location| new_zealand?(location) }.size == 2
86
+ end
87
+
88
+ def response_options
89
+ {
90
+ :rates => @rates,
91
+ :raw_responses => @raw_responses,
92
+ :request => @urls,
93
+ :test => @test
94
+ }
95
+ end
96
+
97
+ def response_params
98
+ { :responses => @responses }
99
+ end
100
+
101
+ def rate_options(products)
102
+ {
103
+ :total_price => products.sum { |product| price(product) },
104
+ :currency => "NZD",
105
+ :service_code => products.first["code"]
106
+ }
107
+ end
108
+
109
+ def rates
110
+ rates_hash.map do |service, products|
111
+ RateEstimate.new(@origin, @destination, NewZealandPost.name, service, rate_options(products))
86
112
  end
87
-
88
- RateResponse.new(true, "Success", Hash.from_xml(response), :rates => rate_estimates, :xml => response)
89
- else
90
- error_message = response_message(xml)
91
- RateResponse.new(false, error_message, Hash.from_xml(response), :rates => rate_estimates, :xml => response)
92
113
  end
93
- end
94
114
 
95
- def combine_rate_responses(rate_responses, packages)
96
- #if there are any failed responses, return on that response
97
- rate_responses.each do |r|
98
- return r if !r.success?
115
+ def rates_hash
116
+ products_hash.select { |service, products| products.size == @packages.size }
99
117
  end
100
118
 
101
- #group rate estimates by delivery type so that we can exclude any incomplete delviery types
102
- rate_estimate_delivery_types = {}
103
- rate_responses.each do |rr|
104
- rr.rate_estimates.each do |re|
105
- (rate_estimate_delivery_types[re.service_code] ||= []) << re
119
+ def products_hash
120
+ product_arrays.flatten.group_by { |product| service_name(product) }
121
+ end
122
+
123
+ def product_arrays
124
+ responses.map do |response|
125
+ raise(response["message"]) unless response["status"] == "success"
126
+ response["products"]
106
127
  end
107
128
  end
108
- rate_estimate_delivery_types.delete_if{ |type, re| re.size != packages.size }
109
-
110
- #combine cost estimates for remaining packages
111
- combined_rate_estimates = []
112
- rate_estimate_delivery_types.each do |type, re|
113
- total_price = re.sum(&:total_price)
114
- r = re.first
115
- combined_rate_estimates << RateEstimate.new(r.origin, r.destination, r.carrier,
116
- r.service_name,
117
- :total_price => total_price,
118
- :currency => r.currency,
119
- :service_code => r.service_code,
120
- :packages => packages)
121
- end
122
- RateResponse.new(true, "Success", {}, :rates => combined_rate_estimates)
129
+
130
+ def responses
131
+ @responses = @raw_responses.map { |response| parse_response(response) }
132
+ end
133
+
134
+ def parse_response(response)
135
+ JSON.parse(response)
136
+ end
137
+
138
+ def url(package)
139
+ "#{URL}/#{api}?#{params(package).to_query}"
140
+ end
141
+
142
+ def params(package)
143
+ @params.merge(api_params).merge(package.params)
144
+ end
145
+
123
146
  end
124
147
 
125
- def response_success?(xml)
126
- xml.get_text('hash/status').to_s == 'success'
148
+ class Domestic < RateRequest
149
+ def service_name(product)
150
+ [ product["service_group_description"], product["description"] ].join(" ")
151
+ end
152
+
153
+ def api
154
+ :domestic
155
+ end
156
+
157
+ def api_params
158
+ {
159
+ :postcode_src => @origin.postal_code,
160
+ :postcode_dest => @destination.postal_code,
161
+ :carrier => "all"
162
+ }
163
+ end
164
+
165
+ def price(product)
166
+ product["cost"].to_f
167
+ end
127
168
  end
128
169
 
129
- def response_message(xml)
130
- if response_success?(xml)
131
- 'Success'
132
- else
133
- xml.get_text('hash/message').to_s
170
+ class International < RateRequest
171
+
172
+ def rates
173
+ raise "New Zealand Post packages must originate in New Zealand" unless new_zealand_origin?
174
+ super
175
+ end
176
+
177
+ def service_name(product)
178
+ [ product["group"], product["name"] ].join(" ")
179
+ end
180
+
181
+ def api
182
+ :international
183
+ end
184
+
185
+ def api_params
186
+ { :country_code => @destination.country_code }
187
+ end
188
+
189
+ def price(product)
190
+ product["price"].to_f
134
191
  end
135
192
  end
136
193
 
194
+ class NewZealandPostPackage
195
+
196
+ def initialize(package, api)
197
+ @package = package
198
+ @api = api
199
+ @params = { :weight => weight, :length => length }
200
+ end
201
+
202
+ def params
203
+ @params.merge(api_params).merge(shape_params)
204
+ end
205
+
206
+ protected
207
+
208
+ def weight
209
+ @package.kg
210
+ end
211
+
212
+ def length
213
+ mm(:length)
214
+ end
215
+
216
+ def height
217
+ mm(:height)
218
+ end
219
+
220
+ def width
221
+ mm(:width)
222
+ end
223
+
224
+ def shape
225
+ return :cylinder if @package.cylinder?
226
+ :cuboid
227
+ end
228
+
229
+ def api_params
230
+ send("#{@api}_params")
231
+ end
232
+
233
+ def international_params
234
+ { :value => value }
235
+ end
236
+
237
+ def domestic_params
238
+ {}
239
+ end
240
+
241
+ def shape_params
242
+ send("#{shape}_params")
243
+ end
244
+
245
+ def cuboid_params
246
+ { :height => height, :thickness => width }
247
+ end
248
+
249
+ def cylinder_params
250
+ { :diameter => width }
251
+ end
252
+
253
+ def mm(measurement)
254
+ @package.cm(measurement) * 10
255
+ end
256
+
257
+ def value
258
+ return 0 unless @package.value && currency == "NZD"
259
+ @package.value / 100
260
+ end
261
+
262
+ def currency
263
+ @package.currency || "NZD"
264
+ end
265
+
266
+ end
137
267
  end
138
268
  end
139
269
  end