active_shipping 0.9.15 → 0.10.0

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