active_shipping 0.12.6 → 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -0
  3. data.tar.gz.sig +0 -0
  4. data/{CHANGELOG → CHANGELOG.md} +6 -2
  5. data/CONTRIBUTING.md +32 -0
  6. data/{README.markdown → README.md} +45 -61
  7. data/lib/active_shipping.rb +20 -28
  8. data/lib/active_shipping/carrier.rb +82 -0
  9. data/lib/active_shipping/carriers.rb +33 -0
  10. data/lib/active_shipping/carriers/benchmark_carrier.rb +31 -0
  11. data/lib/active_shipping/carriers/bogus_carrier.rb +12 -0
  12. data/lib/active_shipping/carriers/canada_post.rb +253 -0
  13. data/lib/active_shipping/carriers/canada_post_pws.rb +870 -0
  14. data/lib/active_shipping/carriers/fedex.rb +579 -0
  15. data/lib/active_shipping/carriers/kunaki.rb +164 -0
  16. data/lib/active_shipping/carriers/new_zealand_post.rb +262 -0
  17. data/lib/active_shipping/carriers/shipwire.rb +181 -0
  18. data/lib/active_shipping/carriers/stamps.rb +861 -0
  19. data/lib/active_shipping/carriers/ups.rb +648 -0
  20. data/lib/active_shipping/carriers/usps.rb +642 -0
  21. data/lib/active_shipping/errors.rb +7 -0
  22. data/lib/active_shipping/label_response.rb +23 -0
  23. data/lib/active_shipping/location.rb +149 -0
  24. data/lib/active_shipping/package.rb +241 -0
  25. data/lib/active_shipping/rate_estimate.rb +64 -0
  26. data/lib/active_shipping/rate_response.rb +13 -0
  27. data/lib/active_shipping/response.rb +41 -0
  28. data/lib/active_shipping/shipment_event.rb +17 -0
  29. data/lib/active_shipping/shipment_packer.rb +73 -0
  30. data/lib/active_shipping/shipping_response.rb +12 -0
  31. data/lib/active_shipping/tracking_response.rb +52 -0
  32. data/lib/active_shipping/version.rb +1 -1
  33. data/lib/vendor/quantified/test/length_test.rb +2 -2
  34. data/lib/vendor/xml_node/test/test_parsing.rb +1 -1
  35. metadata +58 -36
  36. metadata.gz.sig +0 -0
  37. data/lib/active_shipping/shipping/base.rb +0 -13
  38. data/lib/active_shipping/shipping/carrier.rb +0 -84
  39. data/lib/active_shipping/shipping/carriers.rb +0 -23
  40. data/lib/active_shipping/shipping/carriers/benchmark_carrier.rb +0 -33
  41. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +0 -14
  42. data/lib/active_shipping/shipping/carriers/canada_post.rb +0 -257
  43. data/lib/active_shipping/shipping/carriers/canada_post_pws.rb +0 -874
  44. data/lib/active_shipping/shipping/carriers/fedex.rb +0 -581
  45. data/lib/active_shipping/shipping/carriers/kunaki.rb +0 -166
  46. data/lib/active_shipping/shipping/carriers/new_zealand_post.rb +0 -262
  47. data/lib/active_shipping/shipping/carriers/shipwire.rb +0 -184
  48. data/lib/active_shipping/shipping/carriers/stamps.rb +0 -864
  49. data/lib/active_shipping/shipping/carriers/ups.rb +0 -650
  50. data/lib/active_shipping/shipping/carriers/usps.rb +0 -649
  51. data/lib/active_shipping/shipping/errors.rb +0 -9
  52. data/lib/active_shipping/shipping/label_response.rb +0 -25
  53. data/lib/active_shipping/shipping/location.rb +0 -152
  54. data/lib/active_shipping/shipping/package.rb +0 -243
  55. data/lib/active_shipping/shipping/rate_estimate.rb +0 -66
  56. data/lib/active_shipping/shipping/rate_response.rb +0 -15
  57. data/lib/active_shipping/shipping/response.rb +0 -43
  58. data/lib/active_shipping/shipping/shipment_event.rb +0 -19
  59. data/lib/active_shipping/shipping/shipment_packer.rb +0 -75
  60. data/lib/active_shipping/shipping/shipping_response.rb +0 -14
  61. data/lib/active_shipping/shipping/tracking_response.rb +0 -54
@@ -0,0 +1,12 @@
1
+ module ActiveShipping
2
+ class BogusCarrier < Carrier
3
+ cattr_reader :name
4
+ @@name = "Bogus Carrier"
5
+
6
+ def find_rates(origin, destination, packages, options = {})
7
+ origin = Location.from(origin)
8
+ destination = Location.from(destination)
9
+ packages = Array(packages)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,253 @@
1
+ module ActiveShipping
2
+ class CanadaPost < Carrier
3
+ # NOTE!
4
+ # A Merchant CPC Id must be assigned to you by Canada Post
5
+ # CPC_DEMO_XML is just a public domain account for testing
6
+
7
+ class CanadaPostRateResponse < RateResponse
8
+ attr_reader :boxes, :postal_outlets
9
+
10
+ def initialize(success, message, params = {}, options = {})
11
+ @boxes = options[:boxes]
12
+ @postal_outlets = options[:postal_outlets]
13
+ super
14
+ end
15
+ end
16
+
17
+ cattr_reader :name, :name_french
18
+ @@name = "Canada Post"
19
+ @@name_french = "Postes Canada"
20
+
21
+ Box = Struct.new(:name, :weight, :expediter_weight, :length, :width, :height, :packedItems)
22
+ PackedItem = Struct.new(:quantity, :description)
23
+ PostalOutlet = Struct.new(:sequence_no, :distance, :name, :business_name, :postal_address, :business_hours)
24
+
25
+ URL = "http://sellonline.canadapost.ca:30000"
26
+ DOCTYPE = '<!DOCTYPE eparcel SYSTEM "http://sellonline.canadapost.ca/DevelopersResources/protocolV3/eParcel.dtd">'
27
+
28
+ RESPONSE_CODES = {
29
+ '1' => "All calculation was done",
30
+ '2' => "Default shipping rates are returned due to a problem during the processing of the request.",
31
+ '-2' => "Missing argument when calling module",
32
+ '-5' => "No Item to ship",
33
+ '-6' => "Illegal Item weight",
34
+ '-7' => "Illegal item dimension",
35
+ '-12' => "Can't open IM config file",
36
+ '-13' => "Can't create log files",
37
+ '-15' => "Invalid config file format",
38
+ '-102' => "Invalid socket connection",
39
+ '-106' => "Can't connect to server",
40
+ '-1000' => "Unknow request type sent by client",
41
+ '-1002' => "MAS Timed out",
42
+ '-1004' => "Socket communication break",
43
+ '-1005' => "Did not receive required data on socket.",
44
+ '-2000' => "Unable to estabish socket connection with RSSS",
45
+ '-2001' => "Merchant Id not found on server",
46
+ '-2002' => "One or more parameter was not sent by the IM to the MAS",
47
+ '-2003' => "Did not receive required data on socket.",
48
+ '-2004' => "The request contains to many items to process it.",
49
+ '-2005' => "The request received on socket is larger than the maximum allowed.",
50
+ '-3000' => "Origin Postal Code is illegal",
51
+ '-3001' => "Destination Postal Code/State Name/ Country is illegal",
52
+ '-3002' => "Parcel too large to be shipped with CPC",
53
+ '-3003' => "Parcel too small to be shipped with CPC",
54
+ '-3004' => "Parcel too heavy to be shipped with CPC",
55
+ '-3005' => "Internal error code returned by the rating DLL",
56
+ '-3006' => "The pick up time format is invalid or not defined.",
57
+ '-4000' => "Volumetric internal error",
58
+ '-4001' => "Volumetric time out calculation error.",
59
+ '-4002' => "No bins provided to the volumetric engine.",
60
+ '-4003' => "No items provided to the volumetric engine.",
61
+ '-4004' => "Item is too large to be packed",
62
+ '-4005' => "Number of item more than maximum allowed",
63
+ '-5000' => "XML Parsing error",
64
+ '-5001' => "XML Tag not found",
65
+ '-5002' => "Node Value Number format error",
66
+ '-5003' => "Node value is empty",
67
+ '-5004' => "Unable to create/parse XML Document",
68
+ '-6000' => "Unable to open the database",
69
+ '-6001' => "Unable to read from the database",
70
+ '-6002' => "Unable to write to the database",
71
+ '-50000' => "Internal problem - Please contact Sell Online Help Desk"
72
+ }
73
+
74
+ NON_ISO_COUNTRY_NAMES = {
75
+ 'Russian Federation' => 'Russia'
76
+ }
77
+
78
+ def requirements
79
+ [:login]
80
+ end
81
+
82
+ def find_rates(origin, destination, line_items = [], options = {})
83
+ rate_request = build_rate_request(origin, destination, line_items, options)
84
+ commit(rate_request, origin, destination, options)
85
+ end
86
+
87
+ def maximum_weight
88
+ Mass.new(30, :kilograms)
89
+ end
90
+
91
+ def self.default_location
92
+ {
93
+ :country => 'CA',
94
+ :province => 'ON',
95
+ :city => 'Ottawa',
96
+ :address1 => '61A York St',
97
+ :postal_code => 'K1N5T2'
98
+ }
99
+ end
100
+
101
+ protected
102
+
103
+ def commit(request, origin, destination, options = {})
104
+ parse_rate_response(ssl_post(URL, request), origin, destination, options)
105
+ end
106
+
107
+ private
108
+
109
+ def build_rate_request(origin, destination, line_items = [], options = {})
110
+ line_items = [line_items] unless line_items.is_a?(Array)
111
+ origin = origin.is_a?(Location) ? origin : Location.new(origin)
112
+ destination = destination.is_a?(Location) ? destination : Location.new(destination)
113
+
114
+ xml_request = XmlNode.new('eparcel') do |root_node|
115
+ root_node << XmlNode.new('language', @options[:french] ? 'fr' : 'en')
116
+ root_node << XmlNode.new('ratesAndServicesRequest') do |request|
117
+
118
+ request << XmlNode.new('merchantCPCID', @options[:login])
119
+ request << XmlNode.new('fromPostalCode', origin.postal_code)
120
+ request << XmlNode.new('turnAroundTime', options[:turn_around_time]) if options[:turn_around_time]
121
+ request << XmlNode.new('itemsPrice', dollar_amount(line_items.map(&:value).compact.sum))
122
+
123
+ # line items
124
+ request << build_line_items(line_items)
125
+
126
+ # delivery info
127
+ # NOTE: These tags MUST be after line items
128
+ request << XmlNode.new('city', destination.city)
129
+ request << XmlNode.new('provOrState', destination.province)
130
+ request << XmlNode.new('country', handle_non_iso_country_names(destination.country))
131
+ request << XmlNode.new('postalCode', destination.postal_code)
132
+ end
133
+ end
134
+
135
+ DOCTYPE + xml_request.to_s
136
+ end
137
+
138
+ def parse_rate_response(response, origin, destination, options = {})
139
+ xml = REXML::Document.new(response)
140
+ success = response_success?(xml)
141
+ message = response_message(xml)
142
+
143
+ rate_estimates = []
144
+ boxes = []
145
+ if success
146
+ xml.elements.each('eparcel/ratesAndServicesResponse/product') do |product|
147
+ service_name = (@options[:french] ? @@name_french : @@name) + " " + product.get_text('name').to_s
148
+ service_code = product.attribute('id').to_s
149
+
150
+ rate_estimates << RateEstimate.new(origin, destination, @@name, service_name,
151
+ :service_code => service_code,
152
+ :total_price => product.get_text('rate').to_s,
153
+ :currency => 'CAD',
154
+ :shipping_date => product.get_text('shippingDate').to_s,
155
+ :delivery_range => [product.get_text('deliveryDate').to_s] * 2
156
+ )
157
+ end
158
+
159
+ boxes = xml.elements.collect('eparcel/ratesAndServicesResponse/packing/box') do |box|
160
+ b = Box.new
161
+ b.packedItems = []
162
+ b.name = box.get_text('name').to_s
163
+ b.weight = box.get_text('weight').to_s.to_f
164
+ b.expediter_weight = box.get_text('expediterWeight').to_s.to_f
165
+ b.length = box.get_text('length').to_s.to_f
166
+ b.width = box.get_text('width').to_s.to_f
167
+ b.height = box.get_text('height').to_s.to_f
168
+ b.packedItems = box.elements.collect('packedItem') do |item|
169
+ p = PackedItem.new
170
+ p.quantity = item.get_text('quantity').to_s.to_i
171
+ p.description = item.get_text('description').to_s
172
+ p
173
+ end
174
+ b
175
+ end
176
+
177
+ postal_outlets = xml.elements.collect('eparcel/ratesAndServicesResponse/nearestPostalOutlet') do |outlet|
178
+ postal_outlet = PostalOutlet.new
179
+ postal_outlet.sequence_no = outlet.get_text('postalOutletSequenceNo').to_s
180
+ postal_outlet.distance = outlet.get_text('distance').to_s
181
+ postal_outlet.name = outlet.get_text('outletName').to_s
182
+ postal_outlet.business_name = outlet.get_text('businessName').to_s
183
+
184
+ postal_outlet.postal_address = Location.new(
185
+ :address1 => outlet.get_text('postalAddress/addressLine').to_s,
186
+ :postal_code => outlet.get_text('postalAddress/postal_code').to_s,
187
+ :city => outlet.get_text('postalAddress/municipality').to_s,
188
+ :province => outlet.get_text('postalAddress/province').to_s,
189
+ :country => 'Canada',
190
+ :phone_number => outlet.get_text('phoneNumber').to_s
191
+ )
192
+
193
+ postal_outlet.business_hours = outlet.elements.collect('businessHours') do |hour|
194
+ { :day_of_week => hour.get_text('dayOfWeek').to_s, :time => hour.get_text('time').to_s }
195
+ end
196
+
197
+ postal_outlet
198
+ end
199
+ end
200
+
201
+ CanadaPostRateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :boxes => boxes, :postal_outlets => postal_outlets)
202
+ end
203
+
204
+ def response_success?(xml)
205
+ return false unless xml.get_text('eparcel/error').nil?
206
+
207
+ value = xml.get_text('eparcel/ratesAndServicesResponse/statusCode').to_s
208
+ value == '1' || value == '2'
209
+ end
210
+
211
+ def response_message(xml)
212
+ if response_success?(xml)
213
+ xml.get_text('eparcel/ratesAndServicesResponse/statusMessage').to_s
214
+ else
215
+ xml.get_text('eparcel/error/statusMessage').to_s
216
+ end
217
+ end
218
+
219
+ # <!-- List of items in the shopping -->
220
+ # <!-- cart -->
221
+ # <!-- Each item is defined by : -->
222
+ # <!-- - quantity (mandatory) -->
223
+ # <!-- - size (mandatory) -->
224
+ # <!-- - weight (mandatory) -->
225
+ # <!-- - description (mandatory) -->
226
+ # <!-- - ready to ship (optional) -->
227
+
228
+ def build_line_items(line_items)
229
+ XmlNode.new('lineItems') do |line_items_node|
230
+ line_items.each do |line_item|
231
+ line_items_node << XmlNode.new('item') do |item|
232
+ item << XmlNode.new('quantity', 1)
233
+ item << XmlNode.new('weight', line_item.kilograms)
234
+ item << XmlNode.new('length', line_item.cm(:length).to_s)
235
+ item << XmlNode.new('width', line_item.cm(:width).to_s)
236
+ item << XmlNode.new('height', line_item.cm(:height).to_s)
237
+ item << XmlNode.new('description', line_item.options[:description] || ' ')
238
+ item << XmlNode.new('readyToShip', line_item.options[:ready_to_ship] || nil)
239
+ # By setting the 'readyToShip' tag to true, Sell Online will not pack this item in the boxes defined in the merchant profile.
240
+ end
241
+ end
242
+ end
243
+ end
244
+
245
+ def dollar_amount(cents)
246
+ "%0.2f" % (cents / 100.0)
247
+ end
248
+
249
+ def handle_non_iso_country_names(country)
250
+ NON_ISO_COUNTRY_NAMES[country.to_s] || country
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,870 @@
1
+ module ActiveShipping
2
+ class CanadaPostPWS < Carrier
3
+ @@name = "Canada Post PWS"
4
+
5
+ SHIPPING_SERVICES = {
6
+ "DOM.RP" => "Regular Parcel",
7
+ "DOM.EP" => "Expedited Parcel",
8
+ "DOM.XP" => "Xpresspost",
9
+ "DOM.XP.CERT" => "Xpresspost Certified",
10
+ "DOM.PC" => "Priority",
11
+ "DOM.LIB" => "Library Books",
12
+
13
+ "USA.EP" => "Expedited Parcel USA",
14
+ "USA.PW.ENV" => "Priority Worldwide Envelope USA",
15
+ "USA.PW.PAK" => "Priority Worldwide pak USA",
16
+ "USA.PW.PARCEL" => "Priority Worldwide Parcel USA",
17
+ "USA.SP.AIR" => "Small Packet USA Air",
18
+ "USA.SP.SURF" => "Small Packet USA Surface",
19
+ "USA.XP" => "Xpresspost USA",
20
+
21
+ "INT.XP" => "Xpresspost International",
22
+ "INT.IP.AIR" => "International Parcel Air",
23
+ "INT.IP.SURF" => "International Parcel Surface",
24
+ "INT.PW.ENV" => "Priority Worldwide Envelope Int'l",
25
+ "INT.PW.PAK" => "Priority Worldwide pak Int'l",
26
+ "INT.PW.PARCEL" => "Priority Worldwide parcel Int'l",
27
+ "INT.SP.AIR" => "Small Packet International Air",
28
+ "INT.SP.SURF" => "Small Packet International Surface"
29
+ }
30
+
31
+ ENDPOINT = "https://soa-gw.canadapost.ca/" # production
32
+
33
+ SHIPMENT_MIMETYPE = "application/vnd.cpc.ncshipment+xml"
34
+ RATE_MIMETYPE = "application/vnd.cpc.ship.rate+xml"
35
+ TRACK_MIMETYPE = "application/vnd.cpc.track+xml"
36
+ REGISTER_MIMETYPE = "application/vnd.cpc.registration+xml"
37
+
38
+ LANGUAGE = {
39
+ 'en' => 'en-CA',
40
+ 'fr' => 'fr-CA'
41
+ }
42
+
43
+ SHIPPING_OPTIONS = [:d2po, :d2po_office_id, :cov, :cov_amount, :cod, :cod_amount, :cod_includes_shipping,
44
+ :cod_method_of_payment, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad,
45
+ :rase, :rts, :aban]
46
+
47
+ RATES_OPTIONS = [:cov, :cov_amount, :cod, :so, :dc, :dns, :pa18, :pa19, :hfp, :lad]
48
+
49
+ MAX_WEIGHT = 30 # kg
50
+
51
+ attr_accessor :language, :endpoint, :logger, :platform_id, :customer_number
52
+
53
+ def initialize(options = {})
54
+ @language = LANGUAGE[options[:language]] || LANGUAGE['en']
55
+ @endpoint = options[:endpoint] || ENDPOINT
56
+ @platform_id = options[:platform_id]
57
+ @customer_number = options[:customer_number]
58
+ super(options)
59
+ end
60
+
61
+ def requirements
62
+ [:api_key, :secret]
63
+ end
64
+
65
+ def find_rates(origin, destination, line_items = [], options = {}, package = nil, services = [])
66
+ url = endpoint + "rs/ship/price"
67
+ request = build_rates_request(origin, destination, line_items, options, package, services)
68
+ response = ssl_post(url, request, headers(options, RATE_MIMETYPE, RATE_MIMETYPE))
69
+ parse_rates_response(response, origin, destination)
70
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
71
+ error_response(e.response.body, CPPWSRateResponse)
72
+ end
73
+
74
+ def find_tracking_info(pin, options = {})
75
+ response = ssl_get(tracking_url(pin), headers(options, TRACK_MIMETYPE))
76
+ parse_tracking_response(response)
77
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
78
+ if e.response
79
+ error_response(e.response.body, CPPWSTrackingResponse)
80
+ else
81
+ CPPWSTrackingResponse.new(false, e.message, {}, :carrier => @@name)
82
+ end
83
+ rescue InvalidPinFormatError
84
+ CPPWSTrackingResponse.new(false, "Invalid Pin Format", {}, :carrier => @@name)
85
+ end
86
+
87
+ # line_items should be a list of PackageItem's
88
+ def create_shipment(origin, destination, package, line_items = [], options = {})
89
+ request_body = build_shipment_request(origin, destination, package, line_items, options)
90
+ response = ssl_post(create_shipment_url(options), request_body, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
91
+ parse_shipment_response(response)
92
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
93
+ error_response(e.response.body, CPPWSShippingResponse)
94
+ rescue MissingCustomerNumberError
95
+ CPPWSShippingResponse.new(false, "Missing Customer Number", {}, :carrier => @@name)
96
+ end
97
+
98
+ def retrieve_shipment(shipping_id, options = {})
99
+ response = ssl_post(shipment_url(shipping_id, options), nil, headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
100
+ parse_shipment_response(response)
101
+ end
102
+
103
+ def find_shipment_receipt(shipping_id, options = {})
104
+ response = ssl_get(shipment_receipt_url(shipping_id, options), headers(options, SHIPMENT_MIMETYPE, SHIPMENT_MIMETYPE))
105
+ parse_shipment_receipt_response(response)
106
+ end
107
+
108
+ def retrieve_shipping_label(shipping_response, options = {})
109
+ raise MissingShippingNumberError unless shipping_response && shipping_response.shipping_id
110
+ ssl_get(shipping_response.label_url, headers(options, "application/pdf"))
111
+ end
112
+
113
+ def register_merchant(options = {})
114
+ url = endpoint + "ot/token"
115
+ response = ssl_post(url, nil, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE).merge("Content-Length" => "0"))
116
+ parse_register_token_response(response)
117
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
118
+ error_response(e.response.body, CPPWSRegisterResponse)
119
+ end
120
+
121
+ def retrieve_merchant_details(options = {})
122
+ raise MissingTokenIdError unless token_id = options[:token_id]
123
+ url = endpoint + "ot/token/#{token_id}"
124
+ response = ssl_get(url, headers({}, REGISTER_MIMETYPE, REGISTER_MIMETYPE))
125
+ parse_merchant_details_response(response)
126
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
127
+ error_response(e.response.body, CPPWSMerchantDetailsResponse)
128
+ rescue Exception => e
129
+ raise ResponseError.new(e.message)
130
+ end
131
+
132
+ def find_services(country = nil, options = {})
133
+ response = ssl_get(services_url(country), headers(options, RATE_MIMETYPE))
134
+ parse_services_response(response)
135
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
136
+ error_response(e.response.body, CPPWSRateResponse)
137
+ end
138
+
139
+ def find_service_options(service_code, country, options = {})
140
+ response = ssl_get(services_url(country, service_code), headers(options, RATE_MIMETYPE))
141
+ parse_service_options_response(response)
142
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
143
+ error_response(e.response.body, CPPWSRateResponse)
144
+ end
145
+
146
+ def find_option_details(option_code, options = {})
147
+ url = endpoint + "rs/ship/option/#{option_code}"
148
+ response = ssl_get(url, headers(options, RATE_MIMETYPE))
149
+ parse_option_response(response)
150
+ rescue ActiveUtils::ResponseError, ActiveShipping::ResponseError => e
151
+ error_response(e.response.body, CPPWSRateResponse)
152
+ end
153
+
154
+ def maximum_weight
155
+ Mass.new(MAX_WEIGHT, :kilograms)
156
+ end
157
+
158
+ # service discovery
159
+
160
+ def parse_services_response(response)
161
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
162
+ service_nodes = doc.elements['services'].elements.collect('service') { |node| node }
163
+ service_nodes.inject({}) do |result, node|
164
+ service_code = node.get_text("service-code").to_s
165
+ service_name = node.get_text("service-name").to_s
166
+ service_link = node.elements["link"].attributes['href']
167
+ service_link_media_type = node.elements["link"].attributes['media-type']
168
+ result[service_code] = {
169
+ :name => service_name,
170
+ :link => service_link,
171
+ :link_media_type => service_link_media_type
172
+ }
173
+ result
174
+ end
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
+ line_items = Array(line_items)
241
+ xml = XmlNode.new('mailing-scenario', :xmlns => "http://www.canadapost.ca/ws/ship/rate") do |node|
242
+ node << customer_number_node(options)
243
+ node << contract_id_node(options)
244
+ node << quote_type_node(options)
245
+ node << expected_mailing_date_node(shipping_date(options)) if options[:shipping_date]
246
+ options_node = shipping_options_node(RATES_OPTIONS, options)
247
+ node << options_node if options_node && !options_node.children.count.zero?
248
+ node << parcel_node(line_items, package)
249
+ node << origin_node(origin)
250
+ node << destination_node(destination)
251
+ node << services_node(services) unless services.blank?
252
+ end
253
+ xml.to_s
254
+ end
255
+
256
+ def parse_rates_response(response, origin, destination)
257
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
258
+ raise ActiveShipping::ResponseError, "No Quotes" unless doc.elements['price-quotes']
259
+
260
+ quotes = doc.elements['price-quotes'].elements.collect('price-quote') { |node| node }
261
+ rates = quotes.map do |node|
262
+ service_name = node.get_text("service-name").to_s
263
+ service_code = node.get_text("service-code").to_s
264
+ total_price = node.elements['price-details'].get_text("due").to_s
265
+ expected_date = expected_date_from_node(node)
266
+ options = {
267
+ :service_code => service_code,
268
+ :total_price => total_price,
269
+ :currency => 'CAD',
270
+ :delivery_range => [expected_date, expected_date]
271
+ }
272
+ RateEstimate.new(origin, destination, @@name, service_name, options)
273
+ end
274
+ CPPWSRateResponse.new(true, "", {}, :rates => rates)
275
+ end
276
+
277
+ # tracking
278
+
279
+ def parse_tracking_response(response)
280
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
281
+ raise ActiveShipping::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(origin_hash_for(root_node))
291
+ options = {
292
+ :carrier => @@name,
293
+ :service_name => root_node.get_text('service-name').to_s,
294
+ :expected_date => expected_date.blank? ? nil : 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
+ # shipping
323
+
324
+ # options
325
+ # :service => 'DOM.EP'
326
+ # :notification_email
327
+ # :packing_instructions
328
+ # :show_postage_rate
329
+ # :cod, :cod_amount, :insurance, :insurance_amount, :signature_required, :pa18, :pa19, :hfp, :dns, :lad
330
+ #
331
+ def build_shipment_request(origin, destination, package, line_items = [], options = {})
332
+ origin = sanitize_location(origin)
333
+ destination = sanitize_location(destination)
334
+
335
+ xml = XmlNode.new('non-contract-shipment', :xmlns => "http://www.canadapost.ca/ws/ncshipment") do |root_node|
336
+ root_node << XmlNode.new('delivery-spec') do |node|
337
+ node << shipment_service_code_node(options)
338
+ node << shipment_sender_node(origin, options)
339
+ node << shipment_destination_node(destination, options)
340
+ options_node = shipment_options_node(options)
341
+ node << shipment_options_node(options) if options_node && !options_node.children.count.zero?
342
+ node << shipment_parcel_node(package)
343
+ node << shipment_notification_node(options)
344
+ node << shipment_preferences_node(options)
345
+ node << references_node(options) # optional > user defined custom notes
346
+ node << shipment_customs_node(destination, line_items, options)
347
+ # COD Remittance defaults to sender
348
+ end
349
+ end
350
+ xml.to_s
351
+ end
352
+
353
+ def shipment_service_code_node(options)
354
+ XmlNode.new('service-code', options[:service])
355
+ end
356
+
357
+ def shipment_sender_node(location, options)
358
+ XmlNode.new('sender') do |node|
359
+ node << XmlNode.new('name', location.name)
360
+ node << XmlNode.new('company', location.company) if location.company.present?
361
+ node << XmlNode.new('contact-phone', location.phone)
362
+ node << XmlNode.new('address-details') do |innernode|
363
+ innernode << XmlNode.new('address-line-1', location.address1)
364
+ innernode << XmlNode.new('address-line-2', location.address2_and_3) unless location.address2_and_3.blank?
365
+ innernode << XmlNode.new('city', location.city)
366
+ innernode << XmlNode.new('prov-state', location.province)
367
+ # innernode << XmlNode.new('country-code', location.country_code)
368
+ innernode << XmlNode.new('postal-zip-code', location.postal_code)
369
+ end
370
+ end
371
+ end
372
+
373
+ def shipment_destination_node(location, options)
374
+ XmlNode.new('destination') do |node|
375
+ node << XmlNode.new('name', location.name)
376
+ node << XmlNode.new('company', location.company) if location.company.present?
377
+ node << XmlNode.new('client-voice-number', location.phone)
378
+ node << XmlNode.new('address-details') do |innernode|
379
+ innernode << XmlNode.new('address-line-1', location.address1)
380
+ innernode << XmlNode.new('address-line-2', location.address2_and_3) unless location.address2_and_3.blank?
381
+ innernode << XmlNode.new('city', location.city)
382
+ innernode << XmlNode.new('prov-state', location.province) unless location.province.blank?
383
+ innernode << XmlNode.new('country-code', location.country_code)
384
+ innernode << XmlNode.new('postal-zip-code', location.postal_code)
385
+ end
386
+ end
387
+ end
388
+
389
+ def shipment_options_node(options)
390
+ shipping_options_node(SHIPPING_OPTIONS, options)
391
+ end
392
+
393
+ def shipment_notification_node(options)
394
+ return unless options[:notification_email]
395
+ XmlNode.new('notification') do |node|
396
+ node << XmlNode.new('email', options[:notification_email])
397
+ node << XmlNode.new('on-shipment', true)
398
+ node << XmlNode.new('on-exception', true)
399
+ node << XmlNode.new('on-delivery', true)
400
+ end
401
+ end
402
+
403
+ def shipment_preferences_node(options)
404
+ XmlNode.new('preferences') do |node|
405
+ node << XmlNode.new('show-packing-instructions', options[:packing_instructions] || true)
406
+ node << XmlNode.new('show-postage-rate', options[:show_postage_rate] || false)
407
+ node << XmlNode.new('show-insured-value', true)
408
+ end
409
+ end
410
+
411
+ def references_node(options)
412
+ # custom values
413
+ # XmlNode.new('references') do |node|
414
+ # end
415
+ end
416
+
417
+ def shipment_customs_node(destination, line_items, options)
418
+ return unless destination.country_code != 'CA'
419
+
420
+ XmlNode.new('customs') do |node|
421
+ currency = options[:currency] || "CAD"
422
+ node << XmlNode.new('currency', currency)
423
+ node << XmlNode.new('conversion-from-cad', options[:conversion_from_cad].to_s) if currency != 'CAD' && options[:conversion_from_cad]
424
+ node << XmlNode.new('reason-for-export', 'SOG') # SOG - Sale of Goods
425
+ node << XmlNode.new('other-reason', options[:customs_other_reason]) if options[:customs_reason_for_export] && options[:customs_other_reason]
426
+ node << XmlNode.new('additional-customs-info', options[:customs_addition_info]) if options[:customs_addition_info]
427
+ node << XmlNode.new('sku-list') do |sku|
428
+ line_items.each do |line_item|
429
+ sku << XmlNode.new('item') do |item|
430
+ item << XmlNode.new('hs-tariff-code', line_item.hs_code) if line_item.hs_code && !line_item.hs_code.empty?
431
+ item << XmlNode.new('sku', line_item.sku) if line_item.sku && !line_item.sku.empty?
432
+ item << XmlNode.new('customs-description', line_item.name.slice(0, 44))
433
+ item << XmlNode.new('unit-weight', '%#2.3f' % sanitize_weight_kg(line_item.kg))
434
+ item << XmlNode.new('customs-value-per-unit', '%.2f' % sanitize_price_from_cents(line_item.value))
435
+ item << XmlNode.new('customs-number-of-units', line_item.quantity)
436
+ 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?
437
+ 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?
438
+ end
439
+ end
440
+ end
441
+
442
+ end
443
+ end
444
+
445
+ def shipment_parcel_node(package, options = {})
446
+ weight = sanitize_weight_kg(package.kilograms.to_f)
447
+ XmlNode.new('parcel-characteristics') do |el|
448
+ el << XmlNode.new('weight', "%#2.3f" % weight)
449
+ pkg_dim = package.cm
450
+ if pkg_dim && !pkg_dim.select { |x| x != 0 }.empty?
451
+ el << XmlNode.new('dimensions') do |dim|
452
+ dim << XmlNode.new('length', '%.1f' % ((pkg_dim[2] * 10).round / 10.0)) if pkg_dim.size >= 3
453
+ dim << XmlNode.new('width', '%.1f' % ((pkg_dim[1] * 10).round / 10.0)) if pkg_dim.size >= 2
454
+ dim << XmlNode.new('height', '%.1f' % ((pkg_dim[0] * 10).round / 10.0)) if pkg_dim.size >= 1
455
+ end
456
+ end
457
+ el << XmlNode.new('document', false)
458
+ el << XmlNode.new('mailing-tube', package.tube?)
459
+ el << XmlNode.new('unpackaged', package.unpackaged?)
460
+ end
461
+ end
462
+
463
+ def parse_shipment_response(response)
464
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
465
+ raise ActiveShipping::ResponseError, "No Shipping" unless root_node = doc.elements['non-contract-shipment-info']
466
+ options = {
467
+ :shipping_id => root_node.get_text('shipment-id').to_s,
468
+ :tracking_number => root_node.get_text('tracking-pin').to_s,
469
+ :details_url => root_node.elements["links/link[@rel='details']"].attributes['href'],
470
+ :label_url => root_node.elements["links/link[@rel='label']"].attributes['href'],
471
+ :receipt_url => root_node.elements["links/link[@rel='receipt']"].attributes['href']
472
+ }
473
+ CPPWSShippingResponse.new(true, "", {}, options)
474
+ end
475
+
476
+ def parse_register_token_response(response)
477
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
478
+ raise ActiveShipping::ResponseError, "No Registration Token" unless root_node = doc.elements['token']
479
+ options = {
480
+ :token_id => root_node.get_text('token-id').to_s
481
+ }
482
+ CPPWSRegisterResponse.new(true, "", {}, options)
483
+ end
484
+
485
+ def parse_merchant_details_response(response)
486
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
487
+ raise "No Merchant Info" unless root_node = doc.elements['merchant-info']
488
+ raise "No Merchant Info" if root_node.get_text('customer-number').blank?
489
+ options = {
490
+ :customer_number => root_node.get_text('customer-number').to_s,
491
+ :contract_number => root_node.get_text('contract-number').to_s,
492
+ :username => root_node.get_text('merchant-username').to_s,
493
+ :password => root_node.get_text('merchant-password').to_s,
494
+ :has_default_credit_card => root_node.get_text('has-default-credit-card') == 'true'
495
+ }
496
+ CPPWSMerchantDetailsResponse.new(true, "", {}, options)
497
+ end
498
+
499
+ def parse_shipment_receipt_response(response)
500
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
501
+ root = doc.elements['non-contract-shipment-receipt']
502
+ cc_details_node = root.elements['cc-receipt-details']
503
+ service_standard_node = root.elements['service-standard']
504
+ receipt = {
505
+ :final_shipping_point => root.get_text("final-shipping-point").to_s,
506
+ :shipping_point_name => root.get_text("shipping-point-name").to_s,
507
+ :service_code => root.get_text("service-code").to_s,
508
+ :rated_weight => root.get_text("rated-weight").to_s.to_f,
509
+ :base_amount => root.get_text("base-amount").to_s.to_f,
510
+ :pre_tax_amount => root.get_text("pre-tax-amount").to_s.to_f,
511
+ :gst_amount => root.get_text("gst-amount").to_s.to_f,
512
+ :pst_amount => root.get_text("pst-amount").to_s.to_f,
513
+ :hst_amount => root.get_text("hst-amount").to_s.to_f,
514
+ :charge_amount => cc_details_node.get_text("charge-amount").to_s.to_f,
515
+ :currency => cc_details_node.get_text("currency").to_s,
516
+ :expected_transit_days => service_standard_node.get_text("expected-transit-time").to_s.to_i,
517
+ :expected_delivery_date => service_standard_node.get_text("expected-delivery-date").to_s
518
+ }
519
+ option_nodes = root.elements['priced-options'].elements.collect('priced-option') { |node| node } unless root.elements['priced-options'].blank?
520
+
521
+ receipt[:priced_options] = if option_nodes
522
+ option_nodes.inject({}) do |result, node|
523
+ result[node.get_text("option-code").to_s] = node.get_text("option-price").to_s.to_f
524
+ result
525
+ end
526
+ else
527
+ []
528
+ end
529
+
530
+ receipt
531
+ end
532
+
533
+ def error_response(response, response_klass)
534
+ doc = REXML::Document.new(REXML::Text::unnormalize(response))
535
+ messages = doc.elements['messages'].elements.collect('message') { |node| node }
536
+ message = messages.map { |m| m.get_text('description').to_s }.join(", ")
537
+ code = messages.map { |m| m.get_text('code').to_s }.join(", ")
538
+ response_klass.new(false, message, {}, :carrier => @@name, :code => code)
539
+ end
540
+
541
+ def log(msg)
542
+ logger.debug(msg) if logger
543
+ end
544
+
545
+ private
546
+
547
+ def tracking_url(pin)
548
+ case pin.length
549
+ when 12, 13, 16
550
+ endpoint + "vis/track/pin/%s/detail" % pin
551
+ when 15
552
+ endpoint + "vis/track/dnc/%s/detail" % pin
553
+ else
554
+ raise InvalidPinFormatError
555
+ end
556
+ end
557
+
558
+ def create_shipment_url(options)
559
+ raise MissingCustomerNumberError unless customer_number = options[:customer_number]
560
+ if @platform_id.present?
561
+ endpoint + "rs/#{customer_number}-#{@platform_id}/ncshipment"
562
+ else
563
+ endpoint + "rs/#{customer_number}/ncshipment"
564
+ end
565
+ end
566
+
567
+ def shipment_url(shipping_id, options = {})
568
+ raise MissingCustomerNumberError unless customer_number = options[:customer_number]
569
+ if @platform_id.present?
570
+ endpoint + "rs/#{customer_number}-#{@platform_id}/ncshipment/#{shipping_id}"
571
+ else
572
+ endpoint + "rs/#{customer_number}/ncshipment/#{shipping_id}"
573
+ end
574
+ end
575
+
576
+ def shipment_receipt_url(shipping_id, options = {})
577
+ raise MissingCustomerNumberError unless customer_number = options[:customer_number]
578
+ if @platform_id.present?
579
+ endpoint + "rs/#{customer_number}-#{@platform_id}/ncshipment/#{shipping_id}/receipt"
580
+ else
581
+ endpoint + "rs/#{customer_number}/ncshipment/#{shipping_id}/receipt"
582
+ end
583
+ end
584
+
585
+ def services_url(country = nil, service_code = nil)
586
+ url = endpoint + "rs/ship/service"
587
+ url += "/#{service_code}" if service_code
588
+ url += "?country=#{country}" if country
589
+ url
590
+ end
591
+
592
+ def customer_credentials_valid?(credentials)
593
+ (credentials.keys & [:customer_api_key, :customer_secret]).any?
594
+ end
595
+
596
+ def encoded_authorization(customer_credentials = {})
597
+ if customer_credentials_valid?(customer_credentials)
598
+ "Basic %s" % Base64.encode64("#{customer_credentials[:customer_api_key]}:#{customer_credentials[:customer_secret]}")
599
+ else
600
+ "Basic %s" % Base64.encode64("#{@options[:api_key]}:#{@options[:secret]}")
601
+ end
602
+ end
603
+
604
+ def headers(customer_credentials, accept = nil, content_type = nil)
605
+ headers = {
606
+ 'Authorization' => encoded_authorization(customer_credentials),
607
+ 'Accept-Language' => language
608
+ }
609
+ headers['Accept'] = accept if accept
610
+ headers['Content-Type'] = content_type if content_type
611
+ headers['Platform-ID'] = platform_id if platform_id && customer_credentials_valid?(customer_credentials)
612
+ headers
613
+ end
614
+
615
+ def customer_number_node(options)
616
+ XmlNode.new("customer-number", options[:customer_number] || customer_number)
617
+ end
618
+
619
+ def contract_id_node(options)
620
+ XmlNode.new("contract-id", options[:contract_id]) if options[:contract_id]
621
+ end
622
+
623
+ def quote_type_node(options)
624
+ XmlNode.new("quote-type", 'commercial')
625
+ end
626
+
627
+ def expected_mailing_date_node(date_as_string)
628
+ XmlNode.new("expected-mailing-date", date_as_string)
629
+ end
630
+
631
+ def parcel_node(line_items, package = nil, options = {})
632
+ weight = sanitize_weight_kg(package && !package.kilograms.zero? ? package.kilograms.to_f : line_items.sum(&:kilograms).to_f)
633
+ XmlNode.new('parcel-characteristics') do |el|
634
+ el << XmlNode.new('weight', "%#2.3f" % weight)
635
+ if package
636
+ pkg_dim = package.cm
637
+ if pkg_dim && !pkg_dim.select { |x| x != 0 }.empty?
638
+ el << XmlNode.new('dimensions') do |dim|
639
+ dim << XmlNode.new('length', '%.1f' % ((pkg_dim[2] * 10).round / 10.0)) if pkg_dim.size >= 3
640
+ dim << XmlNode.new('width', '%.1f' % ((pkg_dim[1] * 10).round / 10.0)) if pkg_dim.size >= 2
641
+ dim << XmlNode.new('height', '%.1f' % ((pkg_dim[0] * 10).round / 10.0)) if pkg_dim.size >= 1
642
+ end
643
+ end
644
+ end
645
+ el << XmlNode.new('mailing-tube', line_items.any?(&:tube?))
646
+ el << XmlNode.new('oversized', true) if line_items.any?(&:oversized?)
647
+ el << XmlNode.new('unpackaged', line_items.any?(&:unpackaged?))
648
+ end
649
+ end
650
+
651
+ def origin_node(location)
652
+ origin = sanitize_location(location)
653
+ XmlNode.new("origin-postal-code", origin.zip)
654
+ end
655
+
656
+ def destination_node(location)
657
+ destination = sanitize_location(location)
658
+ case destination.country_code
659
+ when 'CA'
660
+ XmlNode.new('destination') do |node|
661
+ node << XmlNode.new('domestic') do |x|
662
+ x << XmlNode.new('postal-code', destination.postal_code)
663
+ end
664
+ end
665
+
666
+ when 'US'
667
+ XmlNode.new('destination') do |node|
668
+ node << XmlNode.new('united-states') do |x|
669
+ x << XmlNode.new('zip-code', destination.postal_code)
670
+ end
671
+ end
672
+
673
+ else
674
+ XmlNode.new('destination') do |dest|
675
+ dest << XmlNode.new('international') do |dom|
676
+ dom << XmlNode.new('country-code', destination.country_code)
677
+ end
678
+ end
679
+ end
680
+ end
681
+
682
+ def services_node(services)
683
+ XmlNode.new('services') do |node|
684
+ services.each { |code| node << XmlNode.new('service-code', code) }
685
+ end
686
+ end
687
+
688
+ def shipping_options_node(available_options, options = {})
689
+ return if (options.symbolize_keys.keys & available_options).empty?
690
+ XmlNode.new('options') do |el|
691
+
692
+ if options[:cod] && options[:cod_amount]
693
+ el << XmlNode.new('option') do |opt|
694
+ opt << XmlNode.new('option-code', 'COD')
695
+ opt << XmlNode.new('option-amount', options[:cod_amount])
696
+ opt << XmlNode.new('option-qualifier-1', options[:cod_includes_shipping]) unless options[:cod_includes_shipping].blank?
697
+ opt << XmlNode.new('option-qualifier-2', options[:cod_method_of_payment]) unless options[:cod_method_of_payment].blank?
698
+ end
699
+ end
700
+
701
+ if options[:cov]
702
+ el << XmlNode.new('option') do |opt|
703
+ opt << XmlNode.new('option-code', 'COV')
704
+ opt << XmlNode.new('option-amount', options[:cov_amount]) unless options[:cov_amount].blank?
705
+ end
706
+ end
707
+
708
+ if options[:d2po]
709
+ el << XmlNode.new('option') do |opt|
710
+ opt << XmlNode.new('option-code', 'D2PO')
711
+ opt << XmlNode.new('option-qualifier-2'. options[:d2po_office_id]) unless options[:d2po_office_id].blank?
712
+ end
713
+ end
714
+
715
+ [:so, :dc, :pa18, :pa19, :hfp, :dns, :lad, :rase, :rts, :aban].each do |code|
716
+ if options[code]
717
+ el << XmlNode.new('option') do |opt|
718
+ opt << XmlNode.new('option-code', code.to_s.upcase)
719
+ end
720
+ end
721
+ end
722
+ end
723
+ end
724
+
725
+ def expected_date_from_node(node)
726
+ if service = node.elements['service-standard']
727
+ expected_date = service.get_text("expected-delivery-date").to_s
728
+ else
729
+ expected_date = nil
730
+ end
731
+ expected_date
732
+ end
733
+
734
+ def shipping_date(options)
735
+ DateTime.strptime((options[:shipping_date] || Time.now).to_s, "%Y-%m-%d")
736
+ end
737
+
738
+ def sanitize_location(location)
739
+ location_hash = location.is_a?(Location) ? location.to_hash : location
740
+ location_hash = sanitize_zip(location_hash)
741
+ Location.new(location_hash)
742
+ end
743
+
744
+ def sanitize_zip(hash)
745
+ [:postal_code, :zip].each do |attr|
746
+ hash[attr].gsub!(/\s+/, '') if hash[attr]
747
+ end
748
+ hash
749
+ end
750
+
751
+ def sanitize_weight_kg(kg)
752
+ kg == 0 ? 0.001 : kg
753
+ end
754
+
755
+ def sanitize_price_from_cents(value)
756
+ value == 0 ? 0.01 : value.round / 100.0
757
+ end
758
+
759
+ def origin_hash_for(root_node)
760
+ occurrences = root_node.get_elements('significant-events').first.get_elements('occurrence')
761
+ earliest = occurrences.sort_by { |occurrence| time_of_occurrence(occurrence) }.first
762
+
763
+ {
764
+ city: earliest.get_text('event-site').to_s,
765
+ province: earliest.get_text('event-province').to_s,
766
+ address_1: earliest.get_text('event-retail-location-id').to_s,
767
+ country: 'Canada'
768
+ }
769
+ end
770
+
771
+ def time_of_occurrence(occurrence)
772
+ time = occurrence.get_text('event-time')
773
+ date = occurrence.get_text('event-date')
774
+ time_zone = occurrence.get_text('event-date')
775
+ DateTime.parse "#{date} #{time} #{time_zone}"
776
+ end
777
+ end
778
+
779
+ module CPPWSErrorResponse
780
+ attr_accessor :error_code
781
+ def handle_error(message, options)
782
+ @error_code = options[:code]
783
+ end
784
+ end
785
+
786
+ class CPPWSRateResponse < RateResponse
787
+ include CPPWSErrorResponse
788
+
789
+ def initialize(success, message, params = {}, options = {})
790
+ handle_error(message, options)
791
+ super
792
+ end
793
+ end
794
+
795
+ class CPPWSTrackingResponse < TrackingResponse
796
+ DELIVERED_EVENT_CODES = %w(1496 1498 1499 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438)
797
+ include CPPWSErrorResponse
798
+
799
+ attr_reader :service_name, :expected_date, :changed_date, :change_reason, :customer_number
800
+
801
+ def initialize(success, message, params = {}, options = {})
802
+ handle_error(message, options)
803
+ super
804
+ @service_name = options[:service_name]
805
+ @expected_date = options[:expected_date]
806
+ @changed_date = options[:changed_date]
807
+ @change_reason = options[:change_reason]
808
+ @customer_number = options[:customer_number]
809
+ end
810
+
811
+ def delivered?
812
+ !delivered_event.nil?
813
+ end
814
+
815
+ def actual_delivery_time
816
+ delivered_event.time if delivered?
817
+ end
818
+
819
+ private
820
+
821
+ def delivered_event
822
+ @delivered_event ||= @shipment_events.detect { |event| DELIVERED_EVENT_CODES.include? event.name }
823
+ end
824
+ end
825
+
826
+ class CPPWSShippingResponse < ShippingResponse
827
+ include CPPWSErrorResponse
828
+ attr_reader :label_url, :details_url, :receipt_url
829
+ def initialize(success, message, params = {}, options = {})
830
+ handle_error(message, options)
831
+ super
832
+ @label_url = options[:label_url]
833
+ @details_url = options[:details_url]
834
+ @receipt_url = options[:receipt_url]
835
+ end
836
+ end
837
+
838
+ class CPPWSRegisterResponse < Response
839
+ include CPPWSErrorResponse
840
+ attr_reader :token_id
841
+ def initialize(success, message, params = {}, options = {})
842
+ handle_error(message, options)
843
+ super
844
+ @token_id = options[:token_id]
845
+ end
846
+
847
+ def redirect_url(customer_id, return_url)
848
+ "http://www.canadapost.ca/cpotools/apps/drc/merchant?return-url=#{CGI.escape(return_url)}&token-id=#{token_id}&platform-id=#{customer_id}"
849
+ end
850
+ end
851
+
852
+ class CPPWSMerchantDetailsResponse < Response
853
+ include CPPWSErrorResponse
854
+ attr_reader :customer_number, :contract_number, :username, :password, :has_default_credit_card
855
+ def initialize(success, message, params = {}, options = {})
856
+ handle_error(message, options)
857
+ super
858
+ @customer_number = options[:customer_number]
859
+ @contract_number = options[:contract_number]
860
+ @username = options[:username]
861
+ @password = options[:password]
862
+ @has_default_credit_card = options[:has_default_credit_card]
863
+ end
864
+ end
865
+
866
+ class InvalidPinFormatError < StandardError; end
867
+ class MissingCustomerNumberError < StandardError; end
868
+ class MissingShippingNumberError < StandardError; end
869
+ class MissingTokenIdError < StandardError; end
870
+ end