forked from ideaoforder/endicia
-
Notifications
You must be signed in to change notification settings - Fork 15
/
Copy pathendicia.rb
464 lines (414 loc) · 16.4 KB
/
endicia.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
require 'rubygems'
require 'httparty'
require 'active_support/core_ext'
require 'builder'
require 'uri'
require 'endicia/label'
require 'endicia/rails_helper'
# Hack fix because Endicia sends response back without protocol in xmlns uri
module HTTParty
class Request
alias_method :parse_response_without_hack, :parse_response
def parse_response(body)
Rails.logger.info("RESPONSE>")
Rails.logger.info(body)
Rails.logger.info("<RESPONSE")
parse_response_without_hack(
body.sub(/xmlns=("|')(www.envmgr.com|LabelServer.Endicia.com)/, 'xmlns=\1https://\2'))
end
end
end
module Endicia
include HTTParty
extend RailsHelper
class EndiciaError < StandardError; end
class InsuranceError < EndiciaError; end
JEWELRY_INSURANCE_EXCLUDED_ZIPS = %w(10036 10017 94102 94108)
# We need the following to make requests
# RequesterID (string): Requester ID (also called Partner ID) uniquely identifies the system making the request. Endicia assigns this ID. The Test Server does not authenticate the RequesterID. Any text value of 1 to 50 characters is valid.
# AccountID (6 digits): Account ID for the Endicia postage account. The Test Server does not authenticate the AccountID. Any 6-digit value is valid.
# PassPhrase (string): Pass Phrase for the Endicia postage account. The Test Server does not authenticate the PassPhrase. Any text value of 1 to 64 characters is valid.
# We probably want the following arguments
# MailClass, WeightOz, MailpieceShape, Machinable, FromPostalCode
format :xml
# example XML
# <LabelRequest><ReturnAddress1>884 Railroad Street, Suite C</ReturnAddress1><ReturnCity>Ypsilanti</ReturnCity><ReturnState>MI</ReturnState><FromPostalCode>48197</FromPostalCode><FromCity>Ypsilanti</FromCity><FromState>MI</FromState><FromCompany>VGKids</FromCompany><ToPostalCode>48197</ToPostalCode><ToAddress1>1237 Elbridge St</ToAddress1><ToCity>Ypsilanti</ToCity><ToState>MI</ToState><PartnerTransactionID>123</PartnerTransactionID><PartnerCustomerID>71212</PartnerCustomerID><MailClass>MediaMail</MailClass><Test>YES</Test><RequesterID>poopants</RequesterID><AccountID>792190</AccountID><PassPhrase>whiplash1</PassPhrase><WeightOz>10</WeightOz></LabelRequest>
# Request a shipping label.
#
# Accepts a hash of options in the form:
# { :NodeOrAttributeName => "value", ... }
#
# See https://app.sgizmo.com/users/4508/Endicia_Label_Server.pdf Table 3-1
# for available options.
#
# Note: options should be specified in a "flat" hash, they should not be
# formated to fit the nesting of the XML.
#
# If you are using rails, any applicable options specified in
# config/endicia.yml will be used as defaults. For example:
#
# development:
# Test: YES
# AccountID: 123
# ...
#
# Returns a Endicia::Label object.
def self.get_label(opts={})
opts = defaults.merge(opts)
opts[:Test] ||= "NO"
url = "#{label_service_url(opts)}/GetPostageLabelXML"
insurance = extract_insurance(opts)
handle_extended_zip_code(opts)
root_keys = :LabelType, :Test, :LabelSize, :ImageFormat, :ImageResolution
root_attributes = extract(opts, root_keys)
root_attributes[:LabelType] ||= "Default"
dimension_keys = :Length, :Width, :Height
mailpiece_dimenions = extract(opts, dimension_keys)
xml = Builder::XmlMarkup.new
body = "labelRequestXML=" + xml.LabelRequest(root_attributes) do |xm|
opts.each { |key, value| xm.tag!(key, value) }
xm.Services({ :InsuredMail => insurance }) if insurance
unless mailpiece_dimenions.empty?
xm.MailpieceDimensions do |md|
mailpiece_dimenions.each { |key, value| md.tag!(key, value) }
end
end
end
result = self.post(url, :body => body)
Endicia::Label.new(result).tap do |the_label|
the_label.request_body = body.to_s
the_label.request_url = url
end
end
# Change your account pass phrase. This is a required step to move to
# production use after requesting an account.
#
# Accepts the new phrase and a hash of options in the form:
#
# { :Name => "value", ... }
#
# See https://app.sgizmo.com/users/4508/Endicia_Label_Server.pdf Table 5-1
# for available/required options.
#
# Note: options should be specified in a "flat" hash, they should not be
# formated to fit the nesting of the XML.
#
# If you are using rails, any applicable options specified in
# config/endicia.yml will be used as defaults. For example:
#
# development:
# Test: YES
# AccountID: 123
# ...
#
# Returns a hash in the form:
#
# {
# :success => true, # or false
# :error_message => "the message", # or nil
# :response_body => "the response body"
# }
def self.change_pass_phrase(new_phrase, options = {})
xml = Builder::XmlMarkup.new
body = "changePassPhraseRequestXML=" + xml.ChangePassPhraseRequest do |xml|
authorize_request(xml, options)
xml.NewPassPhrase new_phrase
xml.RequestID "CPP#{Time.now.to_f}"
end
url = "#{label_service_url(options)}/ChangePassPhraseXML"
result = self.post(url, { :body => body })
parse_result(result, "ChangePassPhraseRequestResponse")
end
# Add postage to your account (submit a RecreditRequest). This is a required
# step to move to production use after requesting an account and changing
# your pass phrase.
#
# Accepts the amount (in dollars) and a hash of options in the form:
#
# { :Name => "value", ... }
#
# See https://app.sgizmo.com/users/4508/Endicia_Label_Server.pdf Table 5-1
# for available/required options.
#
# Note: options should be specified in a "flat" hash, they should not be
# formated to fit the nesting of the XML.
#
# If you are using rails, any applicable options specified in
# config/endicia.yml will be used as defaults. For example:
#
# development:
# Test: YES
# AccountID: 123
# ...
#
# Returns a hash in the form:
#
# {
# :success => true, # or false
# :error_message => "the message", # or nil if no error
# :response_body => "the response body"
# }
def self.buy_postage(amount, options = {})
xml = Builder::XmlMarkup.new
body = "recreditRequestXML=" + xml.RecreditRequest do |xml|
authorize_request(xml, options)
xml.RecreditAmount amount
xml.RequestID "BP#{Time.now.to_f}"
end
url = "#{label_service_url(options)}/BuyPostageXML"
result = self.post(url, { :body => body })
parse_result(result, "RecreditRequestResponse")
end
# Given a tracking number, return a status message for the shipment.
#
# See https://app.sgizmo.com/users/4508/Endicia_Label_Server.pdf Table 12-1
# for available/required options.
#
# Note: options should be specified in a "flat" hash, they should not be
# formated to fit the nesting of the XML.
#
# If you are using rails, any applicable options specified in
# config/endicia.yml will be used as defaults. For example:
#
# development:
# Test: YES
# AccountID: 123
# ...
#
# Returns a hash in the form:
#
# {
# :success => true, # or false
# :error_message => "the message", # or nil if no error
# :status => "the package status", # or nil if error
# :response_body => "the response body"
# }
def self.status_request(tracking_number, options = {})
xml = Builder::XmlMarkup.new.StatusRequest do |xml|
xml.AccountID(options[:AccountID] || defaults[:AccountID])
xml.PassPhrase(options[:PassPhrase] || defaults[:PassPhrase])
xml.Test(options[:Test] || defaults[:Test] || "NO")
xml.FullStatus(options[:FullStatus] || defaults[:FullStatus] || '')
xml.StatusList { |xml| xml.PICNumber(tracking_number) }
end
if options[:logger]
options[:logger].info("ENDICIA REQUEST: #{tracking_number}")
options[:logger].info("\n[REQUEST]")
options[:logger].info(xml)
options[:logger].info("[ENDREQUEST]")
end
params = { :method => 'StatusRequest', :XMLInput => URI.encode(xml) }
result = self.get(els_service_url(params))
response_body = result.body
response_body.gsub!(/<PICNumber>[^<]*/, "<PICNumber>")
response = {
:success => false,
:error_message => nil,
:status => nil,
:response_body => response_body
}
if options[:logger]
options[:logger].info("\n[RESPONSE]")
options[:logger].info(xml)
options[:logger].info("[ENDRESPONSE]")
end
# TODO: It is possible to make a batch status request, currently this only
# supports one at a time. The response that comes back is not parsed
# well by HTTParty. So we have to assume there is only one tracking
# number in order to parse it with a regex.
if result && result = result['StatusResponse']
unless response[:error_message] = result['ErrorMsg']
response[:status] = response_body.match(/<Status>(.+)<\/Status>/)[1]
status_code = response_body.match(/<StatusCode>(.+)<\/StatusCode>/)[1]
response[:success] = (status_code.to_s != '-1')
end
end
response
end
# Given a tracking number, try and void the label generated in a previous call
#
# See https://app.sgizmo.com/users/4508/Endicia_Label_Server.pdf Table 11-1
# for available/required options.
#
# Note: options should be specified in a "flat" hash, they should not be
# formated to fit the nesting of the XML.
#
# If you are using rails, any applicable options specified in
# config/endicia.yml will be used as defaults. For example:
#
# development:
# Test: YES
# AccountID: 123
# ...
#
# Returns a hash in the form:
#
# {
# :success => true, # or false
# :error_message => "the message", # message describing success
# # or failure
# :form_number => 12345, # Form Number for refunded label
# :response_body => "the response body"
# }
def self.refund_request(tracking_number, options = {})
xml = Builder::XmlMarkup.new.RefundRequest do |xml|
xml.AccountID(options[:AccountID] || defaults[:AccountID])
xml.PassPhrase(options[:PassPhrase] || defaults[:PassPhrase])
xml.Test(options[:Test] || defaults[:Test] || "NO")
xml.RefundList { |xml| xml.PICNumber(tracking_number) }
end
params = { :method => 'RefundRequest', :XMLInput => URI.encode(xml) }
result = self.get(els_service_url(params))
response = {
:success => false,
:error_message => nil,
:response_body => result.body
}
# TODO: It is possible to make a batch refund request, currently this only
# supports one at a time. The response that comes back is not parsed
# well by HTTParty. So we have to assume there is only one IsApproved
# and ErrorMsg in order to return them
if result && result = result['RefundResponse']
unless response[:error_message] = result['ErrorMsg']
response[:form_number] = result['FormNumber']
result = result['RefundList']['PICNumber']
response[:success] = (result.match(/<IsApproved>YES<\/IsApproved>/) ? true : false)
response[:error_message] = result.match(/<ErrorMsg>(.+)<\/ErrorMsg>/)[1]
end
end
response
end
# Given a tracking number and package location code,
# return a carrier pickup confirmation.
#
# See https://app.sgizmo.com/users/4508/Endicia_Label_Server.pdf Table 15-1
# for available/required options, and package location codes.
#
# If you are using rails, any applicable options specified in
# config/endicia.yml will be used as defaults. For example:
#
# development:
# Test: YES
# AccountID: 123
# ...
#
# Returns a hash in the form:
#
# {
# :success => true, # or false
# :error_message => "the message", # or nil if no error message
# :error_code => "usps error code", # or nil if no error
# :error_description => "usps error description", # or nil if no error
# :day_of_week => "pickup day of week (ex: Monday)",
# :date => "xx/xx/xxxx", # date of pickup,
# :confirmation_number => "confirmation number of the pickup", # save this!
# :response_body => "the response body"
# }
def self.carrier_pickup_request(tracking_number, package_location, options = {})
xml = Builder::XmlMarkup.new.CarrierPickupRequest do |xml|
xml.AccountID(options.delete(:AccountID) || defaults[:AccountID])
xml.PassPhrase(options.delete(:PassPhrase) || defaults[:PassPhrase])
xml.Test(options.delete(:Test) || defaults[:Test] || "NO")
xml.PackageLocation(package_location)
xml.PickupList { |xml| xml.PICNumber(tracking_number) }
options.each { |key, value| xml.tag!(key, value) }
end
params = { :method => 'CarrierPickupRequest', :XMLInput => URI.encode(xml) }
result = self.get(els_service_url(params))
response = {
:success => false,
:response_body => result.body
}
# TODO: this is some nasty logic...
if result && result = result["CarrierPickupRequestResponse"]
unless response[:error_message] = result['ErrorMsg']
if result = result["Response"]
if error = result.delete("Error")
response[:error_code] = error["Number"]
response[:error_description] = error["Description"]
else
response[:success] = true
end
result.each { |key, value| response[key.underscore.to_sym] = value }
end
end
end
response
end
private
def self.extract(hash, keys)
{}.tap do |return_hash|
keys.each do |key|
value = return_hash[key] = hash.delete(key)
return_hash.delete(key) if value.nil? || value.empty?
end
end
end
# Given a builder object, add the auth nodes required for many api calls.
# Will pull values from options hash or defaults if not found.
def self.authorize_request(xml_builder, options = {})
requester_id = options[:RequesterID] || defaults[:RequesterID]
account_id = options[:AccountID] || defaults[:AccountID]
pass_phrase = options[:PassPhrase] || defaults[:PassPhrase]
xml_builder.RequesterID requester_id
xml_builder.CertifiedIntermediary do |xml_builder|
xml_builder.AccountID account_id
xml_builder.PassPhrase pass_phrase
end
end
# Return the url for making requests.
# Pass options hash with :Test => "YES" to return the url of the test server
# (this matches the Test attribute/node value for most API calls).
def self.label_service_url(options = {})
test = (options[:Test] || defaults[:Test] || "NO").upcase == "YES"
url = test ? "https://www.envmgr.com" : "https://LabelServer.Endicia.com"
"#{url}/LabelService/EwsLabelService.asmx"
end
# Some requests use the ELS service url. This URL is used for requests that
# can accept GET, and have params passed via URL instead of a POST body.
# Pass a hash of params to have them converted to a &key=value string and
# appended to the URL.
def self.els_service_url(params = {})
params = params.to_a.map { |i| "#{i[0]}=#{i[1]}"}.join('&')
"http://www.endicia.com/ELS/ELSServices.cfc?wsdl&#{params}"
end
def self.defaults
if rails? && @defaults.nil?
config_file = File.join(rails_root, 'config', 'endicia.yml')
if File.exist?(config_file)
@defaults = YAML.load_file(config_file)[rails_env].symbolize_keys
end
end
@defaults || {}
end
def self.parse_result(result, root)
parsed_result = {
:success => false,
:error_message => nil,
:response_body => result.body
}
if result && result[root]
root = result[root]
parsed_result[:error_message] = root["ErrorMessage"]
parsed_result[:success] = root["Status"] && root["Status"].to_s == "0"
end
parsed_result
end
# Handle special case where jewelry can't have insurance if sent to certain zips
def self.extract_insurance(opts)
jewelry = opts.delete(:Jewelry)
opts.delete(:InsuredMail).tap do |insurance|
if insurance && insurance == "Endicia" && jewelry
if JEWELRY_INSURANCE_EXCLUDED_ZIPS.include? opts[:ToPostalCode]
raise InsuranceError, "Can't ship jewelry with insurance to #{opts[:ToPostalCode]}"
end
end
end
end
def self.handle_extended_zip_code(opts)
if m = /([0-9]{5})-([0-9]{4})/.match(opts[:ToPostalCode])
opts[:ToPostalCode] = m[1]
opts[:ToZIP4] = m[2]
end
end
end