active_shipping 0.9.15 → 0.10.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.
@@ -1,6 +1,6 @@
1
1
  # Active Shipping
2
2
 
3
- ![Build Status](https://travis-ci.org/Shopify/active_shipping.png)
3
+ [![Build Status](https://travis-ci.org/Shopify/active_shipping.png)](https://travis-ci.org/Shopify/active_shipping)
4
4
 
5
5
  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.
6
6
 
@@ -41,6 +41,7 @@ require 'active_shipping/shipping/base'
41
41
  require 'active_shipping/shipping/response'
42
42
  require 'active_shipping/shipping/rate_response'
43
43
  require 'active_shipping/shipping/tracking_response'
44
+ require 'active_shipping/shipping/shipping_response'
44
45
  require 'active_shipping/shipping/package'
45
46
  require 'active_shipping/shipping/location'
46
47
  require 'active_shipping/shipping/rate_estimate'
@@ -6,13 +6,14 @@ require 'active_shipping/shipping/carriers/shipwire'
6
6
  require 'active_shipping/shipping/carriers/kunaki'
7
7
  require 'active_shipping/shipping/carriers/canada_post'
8
8
  require 'active_shipping/shipping/carriers/new_zealand_post'
9
+ require 'active_shipping/shipping/carriers/canada_post_pws'
9
10
 
10
11
  module ActiveMerchant
11
12
  module Shipping
12
13
  module Carriers
13
14
  class <<self
14
15
  def all
15
- [BogusCarrier, UPS, USPS, FedEx, Shipwire, Kunaki, CanadaPost, NewZealandPost]
16
+ [BogusCarrier, UPS, USPS, FedEx, Shipwire, Kunaki, CanadaPost, NewZealandPost, CanadaPostPWS]
16
17
  end
17
18
  end
18
19
  end
@@ -99,10 +99,10 @@ module ActiveMerchant
99
99
 
100
100
  def self.default_location
101
101
  {
102
- :country => 'CA',
103
- :province => 'ON',
104
- :city => 'Ottawa',
105
- :address1 => '61A York St',
102
+ :country => 'CA',
103
+ :province => 'ON',
104
+ :city => 'Ottawa',
105
+ :address1 => '61A York St',
106
106
  :postal_code => 'K1N5T2'
107
107
  }
108
108
  end
@@ -160,6 +160,7 @@ module ActiveMerchant
160
160
  :service_code => service_code,
161
161
  :total_price => product.get_text('rate').to_s,
162
162
  :currency => 'CAD',
163
+ :shipping_date => product.get_text('shippingDate').to_s,
163
164
  :delivery_range => [product.get_text('deliveryDate').to_s] * 2
164
165
  )
165
166
  end
@@ -0,0 +1,837 @@
1
+ require 'cgi'
2
+
3
+ module ActiveMerchant
4
+ module Shipping
5
+
6
+ class CanadaPostPWS < Carrier
7
+ @@name = "Canada Post PWS"
8
+
9
+ SHIPPING_SERVICES = {
10
+ "DOM.RP" => "Regular Parcel",
11
+ "DOM.EP" => "Expedited Parcel",
12
+ "DOM.XP" => "Xpresspost",
13
+ "DOM.XP.CERT" => "Xpresspost Certified",
14
+ "DOM.PC" => "Priority",
15
+ "DOM.LIB" => "Library Books",
16
+
17
+ "USA.EP" => "Expedited Parcel USA",
18
+ "USA.PW.ENV" => "Priority Worldwide Envelope USA",
19
+ "USA.PW.PAK" => "Priority Worldwide pak USA",
20
+ "USA.PW.PARCEL" => "Priority Worldwide Parcel USA",
21
+ "USA.SP.AIR" => "Small Packet USA Air",
22
+ "USA.SP.SURF" => "Small Packet USA Surface",
23
+ "USA.XP" => "Xpresspost USA",
24
+
25
+ "INT.XP" => "Xpresspost International",
26
+ "INT.IP.AIR" => "International Parcel Air",
27
+ "INT.IP.SURF" => "International Parcel Surface",
28
+ "INT.PW.ENV" => "Priority Worldwide Envelope Int'l",
29
+ "INT.PW.PAK" => "Priority Worldwide pak Int'l",
30
+ "INT.PW.PARCEL" => "Priority Worldwide parcel Int'l",
31
+ "INT.SP.AIR" => "Small Packet International Air",
32
+ "INT.SP.SURF" => "Small Packet International Surface"
33
+ }
34
+
35
+ ENDPOINT = "https://soa-gw.canadapost.ca/" # production
36
+
37
+ SHIPMENT_MIMETYPE = "application/vnd.cpc.ncshipment+xml"
38
+ RATE_MIMETYPE = "application/vnd.cpc.ship.rate+xml"
39
+ TRACK_MIMETYPE = "application/vnd.cpc.track+xml"
40
+ REGISTER_MIMETYPE = "application/vnd.cpc.registration+xml"
41
+
42
+ LANGUAGE = {
43
+ 'en' => 'en-CA',
44
+ 'fr' => 'fr-CA'
45
+ }
46
+
47
+ SHIPPING_OPTIONS = [:d2po, :d2po_office_id, :cov, :cov_amount, :cod, :cod_amount, :cod_includes_shipping,
48
+ :cod_method_of_payment, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad,
49
+ :rase, :rts, :aban]
50
+
51
+ RATES_OPTIONS = [:cov, :cov_amount, :cod, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad]
52
+
53
+ MAX_WEIGHT = 30 # kg
54
+
55
+ attr_accessor :language, :endpoint, :logger, :platform_id
56
+
57
+ def initialize(options = {})
58
+ @language = LANGUAGE[options[:language]] || LANGUAGE['en']
59
+ @endpoint = options[:endpoint] || ENDPOINT
60
+ @platform_id = options[:platform_id]
61
+ super(options)
62
+ end
63
+
64
+ def requirements
65
+ [:api_key, :secret]
66
+ end
67
+
68
+ def find_rates(origin, destination, line_items = [], options = {}, package = nil, services = [])
69
+ url = endpoint + "rs/ship/price"
70
+ request = build_rates_request(origin, destination, line_items, options, package, services)
71
+ response = ssl_post(url, request, headers(options, RATE_MIMETYPE, RATE_MIMETYPE))
72
+ parse_rates_response(response, origin, destination)
73
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
74
+ error_response(e.response.body, CPPWSRateResponse)
75
+ end
76
+
77
+ def find_tracking_info(pin, options = {})
78
+ response = ssl_get(tracking_url(pin), headers(options, TRACK_MIMETYPE))
79
+ parse_tracking_response(response)
80
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
81
+ error_response(e.response.body, CPPWSTrackingResponse)
82
+ rescue InvalidPinFormatError => e
83
+ CPPWSTrackingResponse.new(false, "Invalid Pin Format", {}, {:carrier => @@name})
84
+ end
85
+
86
+ # line_items should be a list of PackageItem's
87
+ def create_shipment(origin, destination, package, line_items = [], options = {})
88
+ request_body = build_shipment_request(origin, destination, package, line_items, options)
89
+ response = ssl_post(create_shipment_url(options), request_body, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
90
+ parse_shipment_response(response)
91
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
92
+ error_response(e.response.body, CPPWSShippingResponse)
93
+ rescue MissingCustomerNumberError => e
94
+ CPPWSShippingResponse.new(false, "Missing Customer Number", {}, {:carrier => @@name})
95
+ end
96
+
97
+ def retrieve_shipment(shipping_id, options = {})
98
+ response = ssl_post(shipment_url(shipping_id, options), nil, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
99
+ shipping_response = parse_shipment_response(response)
100
+ end
101
+
102
+ def find_shipment_receipt(shipping_id, options = {})
103
+ response = ssl_get(shipment_receipt_url(shipping_id, options), headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
104
+ shipping_response = parse_shipment_receipt_response(response)
105
+ end
106
+
107
+ def retrieve_shipping_label(shipping_response, options = {})
108
+ raise MissingShippingNumberError unless shipping_response && shipping_response.shipping_id
109
+ ssl_get(shipping_response.label_url, headers(options, "application/pdf"))
110
+ end
111
+
112
+ def register_merchant(options = {})
113
+ url = endpoint + "ot/token"
114
+ response = ssl_post(url, nil, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE).merge({"Content-Length" => "0"}))
115
+ parse_register_token_response(response)
116
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
117
+ error_response(e.response.body, CPPWSRegisterResponse)
118
+ end
119
+
120
+ def retrieve_merchant_details(options = {})
121
+ raise MissingTokenIdError unless token_id = options[:token_id]
122
+ url = endpoint + "ot/token/#{token_id}"
123
+ response = ssl_get(url, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE))
124
+ parse_merchant_details_response(response)
125
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
126
+ error_response(e.response.body, CPPWSMerchantDetailsResponse)
127
+ rescue Exception => e
128
+ raise ResponseError.new(e.message)
129
+ end
130
+
131
+ def find_services(country = nil, options = {})
132
+ response = ssl_get(services_url(country), headers(options, RATE_MIMETYPE))
133
+ parse_services_response(response)
134
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
135
+ error_response(e.response.body, CPPWSRateResponse)
136
+ end
137
+
138
+ def find_service_options(service_code, country, options = {})
139
+ response = ssl_get(services_url(country, service_code), headers(options, RATE_MIMETYPE))
140
+ parse_service_options_response(response)
141
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
142
+ error_response(e.response.body, CPPWSRateResponse)
143
+ end
144
+
145
+ def find_option_details(option_code, options = {})
146
+ url = endpoint + "rs/ship/option/#{option_code}"
147
+ response = ssl_get(url, headers(options, RATE_MIMETYPE))
148
+ parse_option_response(response)
149
+ rescue ActiveMerchant::ResponseError, ActiveMerchant::Shipping::ResponseError => e
150
+ error_response(e.response.body, CPPWSRateResponse)
151
+ end
152
+
153
+ def maximum_weight
154
+ Mass.new(MAX_WEIGHT, :kilograms)
155
+ end
156
+
157
+ # service discovery
158
+
159
+ def parse_services_response(response)
160
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
161
+ service_nodes = doc.elements['services'].elements.collect('service') {|node| node }
162
+ services = service_nodes.inject({}) do |result, node|
163
+ service_code = node.get_text("service-code").to_s
164
+ service_name = node.get_text("service-name").to_s
165
+ service_link = node.elements["link"].attributes['href']
166
+ service_link_media_type = node.elements["link"].attributes['media-type']
167
+ result[service_code] = {
168
+ :name => service_name,
169
+ :link => service_link,
170
+ :link_media_type => service_link_media_type
171
+ }
172
+ result
173
+ end
174
+ services
175
+ end
176
+
177
+ def parse_service_options_response(response)
178
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
179
+ service_node = doc.elements['service']
180
+ service_code = service_node.get_text("service-code").to_s
181
+ service_name = service_node.get_text("service-name").to_s
182
+ options_node = service_node.elements['options']
183
+ unless options_node.blank?
184
+ option_nodes = options_node.elements.collect('option') {|node| node}
185
+ options = option_nodes.inject([]) do |result, node|
186
+ option = {
187
+ :code => node.get_text("option-code").to_s,
188
+ :name => node.get_text("option-name").to_s,
189
+ :required => node.get_text("mandatory").to_s == "false" ? false : true,
190
+ :qualifier_required => node.get_text("qualifier-required").to_s == "false" ? false : true
191
+ }
192
+ option[:qualifier_max] = node.get_text("qualifier-max").to_s.to_i if node.get_text("qualifier-max")
193
+ result << option
194
+ result
195
+ end
196
+ end
197
+ restrictions_node = service_node.elements['restrictions']
198
+ dimensions_node = restrictions_node.elements['dimensional-restrictions']
199
+ restrictions = {
200
+ :min_weight => restrictions_node.elements["weight-restriction"].attributes['min'].to_i,
201
+ :max_weight => restrictions_node.elements["weight-restriction"].attributes['max'].to_i,
202
+ :min_length => dimensions_node.elements["length"].attributes['min'].to_f,
203
+ :max_length => dimensions_node.elements["length"].attributes['max'].to_f,
204
+ :min_height => dimensions_node.elements["height"].attributes['min'].to_f,
205
+ :max_height => dimensions_node.elements["height"].attributes['max'].to_f,
206
+ :min_width => dimensions_node.elements["width"].attributes['min'].to_f,
207
+ :max_width => dimensions_node.elements["width"].attributes['max'].to_f,
208
+ }
209
+
210
+ {
211
+ :service_code => service_code,
212
+ :service_name => service_name,
213
+ :options => options,
214
+ :restrictions => restrictions
215
+ }
216
+ end
217
+
218
+ def parse_option_response(response)
219
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
220
+ option_node = doc.elements['option']
221
+ conflicts = option_node.elements['conflicting-options'].elements.collect('option-code') {|node| node.get_text.to_s} unless option_node.elements['conflicting-options'].blank?
222
+ prereqs = option_node.elements['prerequisite-options'].elements.collect('option-code') {|node| node.get_text.to_s} unless option_node.elements['prerequisite-options'].blank?
223
+ option = {
224
+ :code => option_node.get_text('option-code').to_s,
225
+ :name => option_node.get_text('option-name').to_s,
226
+ :class => option_node.get_text('option-class').to_s,
227
+ :prints_on_label => option_node.get_text('prints-on-label').to_s == "false" ? false : true,
228
+ :qualifier_required => option_node.get_text('qualifier-required').to_s == "false" ? false : true
229
+ }
230
+ option[:conflicting_options] = conflicts if conflicts
231
+ option[:prerequisite_options] = prereqs if prereqs
232
+
233
+ option[:qualifier_max] = option_node.get_text("qualifier-max").to_s.to_i if option_node.get_text("qualifier-max")
234
+ option
235
+ end
236
+
237
+ # rating
238
+
239
+ def build_rates_request(origin, destination, line_items = [], options = {}, package = nil, services = [])
240
+ xml = XmlNode.new('mailing-scenario', :xmlns => "http://www.canadapost.ca/ws/ship/rate") do |node|
241
+ node << customer_number_node(options)
242
+ node << contract_id_node(options)
243
+ node << quote_type_node(options)
244
+ node << expected_mailing_date_node(shipping_date(options)) if options[:shipping_date]
245
+ options_node = shipping_options_node(RATES_OPTIONS, options)
246
+ node << options_node if options_node && !options_node.children.count.zero?
247
+ node << parcel_node(line_items, package)
248
+ node << origin_node(origin)
249
+ node << destination_node(destination)
250
+ node << services_node(services) unless services.blank?
251
+ end
252
+ xml.to_s
253
+ end
254
+
255
+ def parse_rates_response(response, origin, destination)
256
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
257
+ raise ActiveMerchant::Shipping::ResponseError, "No Quotes" unless doc.elements['price-quotes']
258
+
259
+ quotes = doc.elements['price-quotes'].elements.collect('price-quote') {|node| node }
260
+ rates = quotes.map do |node|
261
+ service_name = node.get_text("service-name").to_s
262
+ service_code = node.get_text("service-code").to_s
263
+ total_price = node.elements['price-details'].get_text("due").to_s
264
+ expected_date = expected_date_from_node(node)
265
+ options = {
266
+ :service_code => service_code,
267
+ :total_price => total_price,
268
+ :currency => 'CAD',
269
+ :delivery_range => [expected_date, expected_date]
270
+ }
271
+ RateEstimate.new(origin, destination, @@name, service_name, options)
272
+ end
273
+ CPPWSRateResponse.new(true, "", {}, :rates => rates)
274
+ end
275
+
276
+
277
+ # tracking
278
+
279
+ def parse_tracking_response(response)
280
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
281
+ raise ActiveMerchant::Shipping::ResponseError, "No Tracking" unless root_node = doc.elements['tracking-detail']
282
+
283
+ events = root_node.elements['significant-events'].elements.collect('occurrence') {|node| node }
284
+
285
+ shipment_events = build_tracking_events(events)
286
+ change_date = root_node.get_text('changed-expected-date').to_s
287
+ expected_date = root_node.get_text('expected-delivery-date').to_s
288
+ dest_postal_code = root_node.get_text('destination-postal-id').to_s
289
+ destination = Location.new(:postal_code => dest_postal_code)
290
+ origin = Location.new({})
291
+ options = {
292
+ :carrier => @@name,
293
+ :service_name => root_node.get_text('service-name').to_s,
294
+ :expected_date => Date.parse(expected_date),
295
+ :changed_date => change_date.blank? ? nil : Date.parse(change_date),
296
+ :change_reason => root_node.get_text('changed-expected-delivery-reason').to_s.strip,
297
+ :destination_postal_code => root_node.get_text('destination-postal-id').to_s,
298
+ :shipment_events => shipment_events,
299
+ :tracking_number => root_node.get_text('pin').to_s,
300
+ :origin => origin,
301
+ :destination => destination,
302
+ :customer_number => root_node.get_text('mailed-by-customer-number').to_s
303
+ }
304
+
305
+ CPPWSTrackingResponse.new(true, "", {}, options)
306
+ end
307
+
308
+ def build_tracking_events(events)
309
+ events.map do |event|
310
+ date = event.get_text('event-date').to_s
311
+ time = event.get_text('event-time').to_s
312
+ zone = event.get_text('event-time-zone').to_s
313
+ timestamp = DateTime.parse("#{date} #{time} #{zone}")
314
+ time = Time.utc(timestamp.utc.year, timestamp.utc.month, timestamp.utc.day, timestamp.utc.hour, timestamp.utc.min, timestamp.utc.sec)
315
+ message = event.get_text('event-description').to_s
316
+ location = [event.get_text('event-retail-name'), event.get_text('event-site'), event.get_text('event-province')].compact.join(", ")
317
+ name = event.get_text('event-identifier').to_s
318
+ ShipmentEvent.new(name, time, location, message)
319
+ end
320
+ end
321
+
322
+
323
+ # shipping
324
+
325
+ # options
326
+ # :service => 'DOM.EP'
327
+ # :notification_email
328
+ # :packing_instructions
329
+ # :show_postage_rate
330
+ # :cod, :cod_amount, :insurance, :insurance_amount, :signature_required, :pa18, :pa19, :hfp, :dns, :lad
331
+ #
332
+ def build_shipment_request(origin_hash, destination_hash, package, line_items = [], options = {})
333
+ origin = Location.new(sanitize_zip(origin_hash))
334
+ destination = Location.new(sanitize_zip(destination_hash))
335
+
336
+ xml = XmlNode.new('non-contract-shipment', :xmlns => "http://www.canadapost.ca/ws/ncshipment") do |root_node|
337
+ root_node << XmlNode.new('delivery-spec') do |node|
338
+ node << shipment_service_code_node(options)
339
+ node << shipment_sender_node(origin, options)
340
+ node << shipment_destination_node(destination, options)
341
+ options_node = shipment_options_node(options)
342
+ node << shipment_options_node(options) if options_node && !options_node.children.count.zero?
343
+ node << shipment_parcel_node(package)
344
+ node << shipment_notification_node(options)
345
+ node << shipment_preferences_node(options)
346
+ node << references_node(options) # optional > user defined custom notes
347
+ node << shipment_customs_node(destination, line_items, options)
348
+ # COD Remittance defaults to sender
349
+ end
350
+ end
351
+ xml.to_s
352
+ end
353
+
354
+ def shipment_service_code_node(options)
355
+ XmlNode.new('service-code', options[:service])
356
+ end
357
+
358
+ def shipment_sender_node(location, options)
359
+ XmlNode.new('sender') do |node|
360
+ node << XmlNode.new('name', location.name)
361
+ node << XmlNode.new('company', location.company) if location.company.present?
362
+ node << XmlNode.new('contact-phone', location.phone)
363
+ node << XmlNode.new('address-details') do |innernode|
364
+ innernode << XmlNode.new('address-line-1', location.address1)
365
+ address2 = [location.address2, location.address3].reject(&:blank?).join(", ")
366
+ innernode << XmlNode.new('address-line-2', address2) unless address2.blank?
367
+ innernode << XmlNode.new('city', location.city)
368
+ innernode << XmlNode.new('prov-state', location.province)
369
+ #innernode << XmlNode.new('country-code', location.country_code)
370
+ innernode << XmlNode.new('postal-zip-code', location.postal_code)
371
+ end
372
+ end
373
+ end
374
+
375
+ def shipment_destination_node(location, options)
376
+ XmlNode.new('destination') do |node|
377
+ node << XmlNode.new('name', location.name)
378
+ node << XmlNode.new('company', location.company) if location.company.present?
379
+ node << XmlNode.new('client-voice-number', location.phone)
380
+ node << XmlNode.new('address-details') do |innernode|
381
+ innernode << XmlNode.new('address-line-1', location.address1)
382
+ address2 = [location.address2, location.address3].reject(&:blank?).join(", ")
383
+ innernode << XmlNode.new('address-line-2', address2) unless address2.blank?
384
+ innernode << XmlNode.new('city', location.city)
385
+ innernode << XmlNode.new('prov-state', location.province) unless location.province.blank?
386
+ innernode << XmlNode.new('country-code', location.country_code)
387
+ innernode << XmlNode.new('postal-zip-code', location.postal_code)
388
+ end
389
+ end
390
+ end
391
+
392
+ def shipment_options_node(options)
393
+ shipping_options_node(SHIPPING_OPTIONS, options)
394
+ end
395
+
396
+ def shipment_notification_node(options)
397
+ return unless options[:notification_email]
398
+ XmlNode.new('notification') do |node|
399
+ node << XmlNode.new('email', options[:notification_email])
400
+ node << XmlNode.new('on-shipment', true)
401
+ node << XmlNode.new('on-exception', true)
402
+ node << XmlNode.new('on-delivery', true)
403
+ end
404
+ end
405
+
406
+ def shipment_preferences_node(options)
407
+ XmlNode.new('preferences') do |node|
408
+ node << XmlNode.new('show-packing-instructions', options[:packing_instructions] || true)
409
+ node << XmlNode.new('show-postage-rate', options[:show_postage_rate] || false)
410
+ node << XmlNode.new('show-insured-value', true)
411
+ end
412
+ end
413
+
414
+ def references_node(options)
415
+ # custom values
416
+ # XmlNode.new('references') do |node|
417
+ # end
418
+ end
419
+
420
+ def shipment_customs_node(destination, line_items, options)
421
+ return unless destination.country_code != 'CA'
422
+
423
+ XmlNode.new('customs') do |node|
424
+ currency = options[:currency] || "CAD"
425
+ node << XmlNode.new('currency',currency)
426
+ node << XmlNode.new('conversion-from-cad',options[:conversion_from_cad].to_s) if currency != 'CAD' && options[:conversion_from_cad]
427
+ node << XmlNode.new('reason-for-export','SOG') # SOG - Sale of Goods
428
+ node << XmlNode.new('other-reason',options[:customs_other_reason]) if (options[:customs_reason_for_export] && options[:customs_other_reason])
429
+ node << XmlNode.new('additional-customs-info',options[:customs_addition_info]) if options[:customs_addition_info]
430
+ node << XmlNode.new('sku-list') do |sku|
431
+ line_items.each do |line_item|
432
+ sku << XmlNode.new('item') do |item|
433
+ item << XmlNode.new('hs-tariff-code', line_item.hs_code) if line_item.hs_code && !line_item.hs_code.empty?
434
+ item << XmlNode.new('sku', line_item.sku) if line_item.sku && !line_item.sku.empty?
435
+ item << XmlNode.new('customs-description', line_item.name.slice(0,44))
436
+ item << XmlNode.new('unit-weight', '%#2.3f' % sanitize_weight_kg(line_item.kg))
437
+ item << XmlNode.new('customs-value-per-unit', '%.2f' % sanitize_price_from_cents(line_item.value))
438
+ item << XmlNode.new('customs-number-of-units', line_item.quantity)
439
+ item << XmlNode.new('country-of-origin', line_item.options[:country_of_origin]) if line_item.options && line_item.options[:country_of_origin] && !line_item.options[:country_of_origin].empty?
440
+ item << XmlNode.new('province-of-origin', line_item.options[:province_of_origin]) if line_item.options && line_item.options[:province_of_origin] && !line_item.options[:province_of_origin].empty?
441
+ end
442
+ end
443
+ end
444
+
445
+ end
446
+ end
447
+
448
+ def shipment_parcel_node(package, options ={})
449
+ weight = sanitize_weight_kg(package.kilograms.to_f)
450
+ XmlNode.new('parcel-characteristics') do |el|
451
+ el << XmlNode.new('weight', "%#2.3f" % weight)
452
+ pkg_dim = package.cm
453
+ if pkg_dim && !pkg_dim.select{|x| x != 0}.empty?
454
+ el << XmlNode.new('dimensions') do |dim|
455
+ dim << XmlNode.new('length', '%.1f' % ((pkg_dim[2]*10).round / 10.0)) if pkg_dim.size >= 3
456
+ dim << XmlNode.new('width', '%.1f' % ((pkg_dim[1]*10).round / 10.0)) if pkg_dim.size >= 2
457
+ dim << XmlNode.new('height', '%.1f' % ((pkg_dim[0]*10).round / 10.0)) if pkg_dim.size >= 1
458
+ end
459
+ end
460
+ el << XmlNode.new('document', false)
461
+ el << XmlNode.new('mailing-tube', package.tube?)
462
+ el << XmlNode.new('unpackaged', package.unpackaged?)
463
+ end
464
+ end
465
+
466
+
467
+ def parse_shipment_response(response)
468
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
469
+ raise ActiveMerchant::Shipping::ResponseError, "No Shipping" unless root_node = doc.elements['non-contract-shipment-info']
470
+ options = {
471
+ :shipping_id => root_node.get_text('shipment-id').to_s,
472
+ :tracking_number => root_node.get_text('tracking-pin').to_s,
473
+ :details_url => root_node.elements["links/link[@rel='details']"].attributes['href'],
474
+ :label_url => root_node.elements["links/link[@rel='label']"].attributes['href'],
475
+ :receipt_url => root_node.elements["links/link[@rel='receipt']"].attributes['href']
476
+ }
477
+ CPPWSShippingResponse.new(true, "", {}, options)
478
+ end
479
+
480
+ def parse_register_token_response(response)
481
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
482
+ raise ActiveMerchant::Shipping::ResponseError, "No Registration Token" unless root_node = doc.elements['token']
483
+ options = {
484
+ :token_id => root_node.get_text('token-id').to_s
485
+ }
486
+ CPPWSRegisterResponse.new(true, "", {}, options)
487
+ end
488
+
489
+ def parse_merchant_details_response(response)
490
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
491
+ raise "No Merchant Info" unless root_node = doc.elements['merchant-info']
492
+ raise "No Merchant Info" if root_node.get_text('customer-number').blank?
493
+ options = {
494
+ :customer_number => root_node.get_text('customer-number').to_s,
495
+ :contract_number => root_node.get_text('contract-number').to_s,
496
+ :username => root_node.get_text('merchant-username').to_s,
497
+ :password => root_node.get_text('merchant-password').to_s,
498
+ :has_default_credit_card => root_node.get_text('has-default-credit-card') == 'true'
499
+ }
500
+ CPPWSMerchantDetailsResponse.new(true, "", {}, options)
501
+ end
502
+
503
+ def parse_shipment_receipt_response(response)
504
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
505
+ root = doc.elements['non-contract-shipment-receipt']
506
+ cc_details_node = root.elements['cc-receipt-details']
507
+ service_standard_node = root.elements['service-standard']
508
+ receipt = {
509
+ :final_shipping_point => root.get_text("final-shipping-point").to_s,
510
+ :shipping_point_name => root.get_text("shipping-point-name").to_s,
511
+ :service_code => root.get_text("service-code").to_s,
512
+ :rated_weight => root.get_text("rated-weight").to_s.to_f,
513
+ :base_amount => root.get_text("base-amount").to_s.to_f,
514
+ :pre_tax_amount => root.get_text("pre-tax-amount").to_s.to_f,
515
+ :gst_amount => root.get_text("gst-amount").to_s.to_f,
516
+ :pst_amount => root.get_text("pst-amount").to_s.to_f,
517
+ :hst_amount => root.get_text("hst-amount").to_s.to_f,
518
+ :charge_amount => cc_details_node.get_text("charge-amount").to_s.to_f,
519
+ :currency => cc_details_node.get_text("currency").to_s,
520
+ :expected_transit_days => service_standard_node.get_text("expected-transit-time").to_s.to_i,
521
+ :expected_delivery_date => service_standard_node.get_text("expected-delivery-date").to_s
522
+ }
523
+ option_nodes = root.elements['priced-options'].elements.collect('priced-option') {|node| node} unless root.elements['priced-options'].blank?
524
+
525
+ receipt[:priced_options] = if option_nodes
526
+ option_nodes.inject({}) do |result, node|
527
+ result[node.get_text("option-code").to_s] = node.get_text("option-price").to_s.to_f
528
+ result
529
+ end
530
+ else
531
+ []
532
+ end
533
+
534
+ receipt
535
+ end
536
+
537
+ def error_response(response, response_klass)
538
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
539
+ messages = doc.elements['messages'].elements.collect('message') {|node| node }
540
+ message = messages.map {|m| m.get_text('description').to_s }.join(", ")
541
+ code = messages.map {|m| m.get_text('code').to_s }.join(", ")
542
+ response_klass.new(false, message, {}, {:carrier => @@name, :code => code})
543
+ end
544
+
545
+ def log(msg)
546
+ logger.debug(msg) if logger
547
+ end
548
+
549
+ private
550
+
551
+ def tracking_url(pin)
552
+ case pin.length
553
+ when 12,13,16
554
+ endpoint + "vis/track/pin/%s/detail" % pin
555
+ when 15
556
+ endpoint + "vis/track/dnc/%s/detail" % pin
557
+ else
558
+ raise InvalidPinFormatError
559
+ end
560
+ end
561
+
562
+ def create_shipment_url(options)
563
+ raise MissingCustomerNumberError unless customer_number = options[:customer_number]
564
+ if @platform_id.present?
565
+ endpoint + "rs/#{customer_number}-#{@platform_id}/ncshipment"
566
+ else
567
+ endpoint + "rs/#{customer_number}/ncshipment"
568
+ end
569
+ end
570
+
571
+ def shipment_url(shipping_id, options={})
572
+ raise MissingCustomerNumberError unless customer_number = options[:customer_number]
573
+ if @platform_id.present?
574
+ endpoint + "rs/#{customer_number}-#{@platform_id}/ncshipment/#{shipping_id}"
575
+ else
576
+ endpoint + "rs/#{customer_number}/ncshipment/#{shipping_id}"
577
+ end
578
+ end
579
+
580
+ def shipment_receipt_url(shipping_id, options={})
581
+ raise MissingCustomerNumberError unless customer_number = options[:customer_number]
582
+ if @platform_id.present?
583
+ endpoint + "rs/#{customer_number}-#{@platform_id}/ncshipment/#{shipping_id}/receipt"
584
+ else
585
+ endpoint + "rs/#{customer_number}/ncshipment/#{shipping_id}/receipt"
586
+ end
587
+ end
588
+
589
+ def services_url(country=nil, service_code=nil)
590
+ url = endpoint + "rs/ship/service"
591
+ url += "/#{service_code}" if service_code
592
+ url += "?country=#{country}" if country
593
+ url
594
+ end
595
+
596
+ def customer_credentials_valid?(credentials)
597
+ (credentials.keys & [:customer_api_key, :customer_secret]).any?
598
+ end
599
+
600
+ def encoded_authorization(customer_credentials = {})
601
+ if customer_credentials_valid?(customer_credentials)
602
+ "Basic %s" % Base64.encode64("#{customer_credentials[:customer_api_key]}:#{customer_credentials[:customer_secret]}")
603
+ else
604
+ "Basic %s" % Base64.encode64("#{@options[:api_key]}:#{@options[:secret]}")
605
+ end
606
+ end
607
+
608
+ def headers(customer_credentials, accept = nil, content_type = nil)
609
+ headers = {
610
+ 'Authorization' => encoded_authorization(customer_credentials),
611
+ 'Accept-Language' => language
612
+ }
613
+ headers['Accept'] = accept if accept
614
+ headers['Content-Type'] = content_type if content_type
615
+ headers['Platform-ID'] = platform_id if platform_id && customer_credentials_valid?(customer_credentials)
616
+ headers
617
+ end
618
+
619
+ def customer_number_node(options)
620
+ XmlNode.new("customer-number", options[:customer_number])
621
+ end
622
+
623
+ def contract_id_node(options)
624
+ XmlNode.new("contract-id", options[:contract_id]) if options[:contract_id]
625
+ end
626
+
627
+ def quote_type_node(options)
628
+ XmlNode.new("quote-type", 'commercial')
629
+ end
630
+
631
+ def expected_mailing_date_node(date_as_string)
632
+ XmlNode.new("expected-mailing-date", date_as_string)
633
+ end
634
+
635
+ def parcel_node(line_items, package = nil, options ={})
636
+ weight = sanitize_weight_kg(package && !package.kilograms.zero? ? package.kilograms.to_f : line_items.sum(&:kilograms).to_f)
637
+ XmlNode.new('parcel-characteristics') do |el|
638
+ el << XmlNode.new('weight', "%#2.3f" % weight)
639
+ if package
640
+ pkg_dim = package.cm
641
+ if pkg_dim && !pkg_dim.select{|x| x != 0}.empty?
642
+ el << XmlNode.new('dimensions') do |dim|
643
+ dim << XmlNode.new('length', '%.1f' % ((pkg_dim[2]*10).round / 10.0)) if pkg_dim.size >= 3
644
+ dim << XmlNode.new('width', '%.1f' % ((pkg_dim[1]*10).round / 10.0)) if pkg_dim.size >= 2
645
+ dim << XmlNode.new('height', '%.1f' % ((pkg_dim[0]*10).round / 10.0)) if pkg_dim.size >= 1
646
+ end
647
+ end
648
+ end
649
+ el << XmlNode.new('mailing-tube', line_items.any?(&:tube?))
650
+ el << XmlNode.new('oversized', true) if line_items.any?(&:oversized?)
651
+ el << XmlNode.new('unpackaged', line_items.any?(&:unpackaged?))
652
+ end
653
+ end
654
+
655
+ def origin_node(location_hash)
656
+ origin = Location.new(sanitize_zip(location_hash))
657
+ XmlNode.new("origin-postal-code", origin.zip)
658
+ end
659
+
660
+ def destination_node(location_hash)
661
+ destination = Location.new(sanitize_zip(location_hash))
662
+ case destination.country_code
663
+ when 'CA'
664
+ XmlNode.new('destination') do |node|
665
+ node << XmlNode.new('domestic') do |x|
666
+ x << XmlNode.new('postal-code', destination.postal_code)
667
+ end
668
+ end
669
+
670
+ when 'US'
671
+ XmlNode.new('destination') do |node|
672
+ node << XmlNode.new('united-states') do |x|
673
+ x << XmlNode.new('zip-code', destination.postal_code)
674
+ end
675
+ end
676
+
677
+ else
678
+ XmlNode.new('destination') do |dest|
679
+ dest << XmlNode.new('international') do |dom|
680
+ dom << XmlNode.new('country-code', destination.country_code)
681
+ end
682
+ end
683
+ end
684
+ end
685
+
686
+ def services_node(services)
687
+ XmlNode.new('services') do |node|
688
+ services.each {|code| node << XmlNode.new('service-code', code)}
689
+ end
690
+ end
691
+
692
+ def shipping_options_node(available_options, options = {})
693
+ return if (options.symbolize_keys.keys & available_options).empty?
694
+ XmlNode.new('options') do |el|
695
+
696
+ if options[:cod] && options[:cod_amount]
697
+ el << XmlNode.new('option') do |opt|
698
+ opt << XmlNode.new('option-code', 'COD')
699
+ opt << XmlNode.new('option-amount', options[:cod_amount])
700
+ opt << XmlNode.new('option-qualifier-1', options[:cod_includes_shipping]) unless options[:cod_includes_shipping].blank?
701
+ opt << XmlNode.new('option-qualifier-2', options[:cod_method_of_payment]) unless options[:cod_method_of_payment].blank?
702
+ end
703
+ end
704
+
705
+ if options[:cov]
706
+ el << XmlNode.new('option') do |opt|
707
+ opt << XmlNode.new('option-code', 'COV')
708
+ opt << XmlNode.new('option-amount', options[:cov_amount]) unless options[:cov_amount].blank?
709
+ end
710
+ end
711
+
712
+ if options[:d2po]
713
+ el << XmlNode.new('option') do |opt|
714
+ opt << XmlNode.new('option-code', 'D2PO')
715
+ opt << XmlNode.new('option-qualifier-2'. options[:d2po_office_id]) unless options[:d2po_office_id].blank?
716
+ end
717
+ end
718
+
719
+ [:so, :dc, :pa18, :pa19, :hfp, :dns, :lad, :rase, :rts, :aban].each do |code|
720
+ if options[code]
721
+ el << XmlNode.new('option') do |opt|
722
+ opt << XmlNode.new('option-code', code.to_s.upcase)
723
+ end
724
+ end
725
+ end
726
+ end
727
+ end
728
+
729
+ def expected_date_from_node(node)
730
+ if service = node.elements['service-standard']
731
+ expected_date = service.get_text("expected-delivery-date").to_s
732
+ else
733
+ expected_date = nil
734
+ end
735
+ expected_date
736
+ end
737
+
738
+ def shipping_date(options)
739
+ DateTime.strptime((options[:shipping_date] || Time.now).to_s, "%Y-%m-%d")
740
+ end
741
+
742
+ def sanitize_zip(hash)
743
+ [:postal_code, :zip].each do |attr|
744
+ hash[attr].gsub!(/\s+/,'') if hash[attr]
745
+ end
746
+ hash
747
+ end
748
+
749
+ def sanitize_weight_kg(kg)
750
+ return kg == 0 ? 0.001 : kg;
751
+ end
752
+
753
+ def sanitize_price_from_cents(value)
754
+ return value == 0 ? 0.01 : value.round / 100.0
755
+ end
756
+
757
+ end
758
+
759
+ module CPPWSErrorResponse
760
+ attr_accessor :error_code
761
+ def handle_error(message, options)
762
+ @error_code = options[:code]
763
+ end
764
+ end
765
+
766
+ class CPPWSRateResponse < RateResponse
767
+ include CPPWSErrorResponse
768
+
769
+ def initialize(success, message, params = {}, options = {})
770
+ handle_error(message, options)
771
+ super
772
+ end
773
+ end
774
+
775
+ class CPPWSTrackingResponse < TrackingResponse
776
+ include CPPWSErrorResponse
777
+
778
+ attr_reader :service_name, :expected_date, :changed_date, :change_reason, :customer_number
779
+
780
+ def initialize(success, message, params = {}, options = {})
781
+ handle_error(message, options)
782
+ super
783
+ @service_name = options[:service_name]
784
+ @expected_date = options[:expected_date]
785
+ @changed_date = options[:changed_date]
786
+ @change_reason = options[:change_reason]
787
+ @customer_number = options[:customer_number]
788
+ end
789
+ end
790
+
791
+ class CPPWSShippingResponse < ShippingResponse
792
+ include CPPWSErrorResponse
793
+ attr_reader :label_url, :details_url, :receipt_url
794
+ def initialize(success, message, params = {}, options = {})
795
+ handle_error(message, options)
796
+ super
797
+ @label_url = options[:label_url]
798
+ @details_url = options[:details_url]
799
+ @receipt_url = options[:receipt_url]
800
+ end
801
+ end
802
+
803
+ class CPPWSRegisterResponse < Response
804
+ include CPPWSErrorResponse
805
+ attr_reader :token_id
806
+ def initialize(success, message, params = {}, options = {})
807
+ handle_error(message, options)
808
+ super
809
+ @token_id = options[:token_id]
810
+ end
811
+
812
+ def redirect_url(customer_id, return_url)
813
+ "http://www.canadapost.ca/cpotools/apps/drc/merchant?return-url=#{CGI::escape(return_url)}&token-id=#{token_id}&platform-id=#{customer_id}"
814
+ end
815
+ end
816
+
817
+ class CPPWSMerchantDetailsResponse < Response
818
+ include CPPWSErrorResponse
819
+ attr_reader :customer_number, :contract_number, :username, :password, :has_default_credit_card
820
+ def initialize(success, message, params = {}, options = {})
821
+ handle_error(message, options)
822
+ super
823
+ @customer_number = options[:customer_number]
824
+ @contract_number = options[:contract_number]
825
+ @username = options[:username]
826
+ @password = options[:password]
827
+ @has_default_credit_card = options[:has_default_credit_card]
828
+ end
829
+ end
830
+
831
+ class InvalidPinFormatError < StandardError; end
832
+ class MissingCustomerNumberError < StandardError; end
833
+ class MissingShippingNumberError < StandardError; end
834
+ class MissingTokenIdError < StandardError; end
835
+
836
+ end
837
+ end