shipppit-canada-post 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +312 -0
- data/Rakefile +2 -0
- data/canada-post-api.gemspec +32 -0
- data/lib/canada_post.rb +14 -0
- data/lib/canada_post/client.rb +45 -0
- data/lib/canada_post/credentials.rb +17 -0
- data/lib/canada_post/helpers.rb +15 -0
- data/lib/canada_post/rate.rb +27 -0
- data/lib/canada_post/request/base.rb +155 -0
- data/lib/canada_post/request/manifest.rb +103 -0
- data/lib/canada_post/request/rate.rb +122 -0
- data/lib/canada_post/request/registration.rb +49 -0
- data/lib/canada_post/request/shipment.rb +96 -0
- data/lib/canada_post/request/shipping.rb +232 -0
- data/lib/canada_post/shipment.rb +68 -0
- data/lib/canada_post/version.rb +3 -0
- data/spec/config/canada_post_credentials.example.yml +11 -0
- data/spec/manifest_spec.rb +40 -0
- data/spec/rate_spec.rb +96 -0
- data/spec/registration_spec.rb +27 -0
- data/spec/shipping_spec.rb +137 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/support/credentials.rb +16 -0
- data/spec/support/vcr.rb +14 -0
- metadata +197 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
module CanadaPost
|
2
|
+
class Credentials
|
3
|
+
include Helpers
|
4
|
+
|
5
|
+
attr_reader :username, :password, :customer_number, :mode
|
6
|
+
|
7
|
+
def initialize(options={})
|
8
|
+
requires!(options, :username, :password, :customer_number, :mode)
|
9
|
+
@username = options[:username]
|
10
|
+
@password = options[:password]
|
11
|
+
@customer_number = options[:customer_number]
|
12
|
+
@mode = options[:mode]
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module CanadaPost
|
2
|
+
module Helpers
|
3
|
+
|
4
|
+
private
|
5
|
+
# Helper method to validate required fields
|
6
|
+
def requires!(hash, *params)
|
7
|
+
params.each { |param| raise RateError, "Missing Required Parameter #{param}" if hash[param].nil? }
|
8
|
+
end
|
9
|
+
|
10
|
+
def underscorize(key) #:nodoc:
|
11
|
+
key.to_s.sub(/^(v[0-9]+|ns):/, "").gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').gsub(/([a-z\d])([A-Z])/,'\1_\2').downcase
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module CanadaPost
|
2
|
+
class Rate
|
3
|
+
|
4
|
+
attr_accessor :service_type, :service_code, :service_link, :total_net_charge, :rate_type,
|
5
|
+
:total_base_charge, :guaranteed_delivery, :expected_transit_time,
|
6
|
+
:expected_delivery_date, :am_delivery
|
7
|
+
|
8
|
+
def initialize(options={})
|
9
|
+
@service_type = options[:service_name]
|
10
|
+
@service_code = options[:service_code]
|
11
|
+
@service_link = options[:service_link]
|
12
|
+
@total_net_charge = options[:price_details][:due]
|
13
|
+
@total_base_charge = options[:price_details][:base]
|
14
|
+
@gst_taxes = options[:price_details][:taxes][:gst]
|
15
|
+
@pst_taxes = options[:price_details][:taxes][:pst]
|
16
|
+
@hst_taxes = options[:price_details][:taxes][:hst]
|
17
|
+
@expected_transit_time = options[:service_standard][:expected_transit_time]
|
18
|
+
@expected_delivery_date = options[:service_standard][:expected_delivery_date]
|
19
|
+
@guaranteed_delivery = options[:service_standard][:guaranteed_delivery]
|
20
|
+
@am_delivery = options[:service_standard][:am_delivery]
|
21
|
+
end
|
22
|
+
|
23
|
+
def total_taxes
|
24
|
+
(@gst_taxes.to_f + @pst_taxes.to_f + @hst_taxes.to_f).to_s
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
require 'httparty'
|
2
|
+
require 'nokogiri'
|
3
|
+
require 'active_support/core_ext/hash'
|
4
|
+
require 'canada_post/helpers'
|
5
|
+
require 'canada_post/rate'
|
6
|
+
require 'canada_post/shipment'
|
7
|
+
|
8
|
+
module CanadaPost
|
9
|
+
module Request
|
10
|
+
class Base
|
11
|
+
include Helpers
|
12
|
+
include HTTParty
|
13
|
+
|
14
|
+
# CanadaPost API Test URL
|
15
|
+
TEST_URL = "https://ct.soa-gw.canadapost.ca"
|
16
|
+
|
17
|
+
# CanadaPost API Production URL
|
18
|
+
PRODUCTION_URL = "https://soa-gw.canadapost.ca"
|
19
|
+
|
20
|
+
# CanadaPost API TEST CONTRACT ID
|
21
|
+
TEST_CONTRACT_ID = "0042708517"
|
22
|
+
|
23
|
+
# List of available Option Codes
|
24
|
+
# SO - Signature
|
25
|
+
# COV - Coverage (requires qualifier)
|
26
|
+
# COD - COD (requires qualifier)
|
27
|
+
# PA18 - Proof of Age Required - 18
|
28
|
+
# PA19 - Proof of Age Required - 19
|
29
|
+
# HFP - Card for pickup
|
30
|
+
# DNS - Do not safe drop
|
31
|
+
# LAD - Leave at door - do not card
|
32
|
+
OPTION_CODES = ["SO", "COV", "COD", "PA18", "PA19", "HFP", "DNS", "LAD"]
|
33
|
+
|
34
|
+
# List of available Service Codes
|
35
|
+
# DOM.RP - Regular Parcel
|
36
|
+
# DOM.EP - Expedited Parcel
|
37
|
+
# DOM.XP - Xpresspost
|
38
|
+
# DOM.XP.CERT - Xpresspost Certified
|
39
|
+
# DOM.PC - Priority
|
40
|
+
# DOM.DT - Delivered Tonight
|
41
|
+
# DOM.LIB - Library Books
|
42
|
+
# USA.EP - Expedited Parcel USA
|
43
|
+
# USA.PW.ENV - Priority Worldwide Envelope USA
|
44
|
+
# USA.PW.PAK - Priority Worldwide pak USA
|
45
|
+
# USA.PW.Parcel - Priority Worldwide Parcel USA
|
46
|
+
# USA.SP.AIR - Small Packet USA Air
|
47
|
+
# USA.TP - Tracked Package - USA
|
48
|
+
# USA.TP.LVM - Tracked Package - USA (LVM) (large volume mailers)
|
49
|
+
# USA.XP - Xpresspost USA
|
50
|
+
# INT.XP - Xpresspost international
|
51
|
+
# INT.IP.AIR - International Parcel Air
|
52
|
+
# INT.IP.SURF - International Parcel Surface
|
53
|
+
# INT.PW.ENV - Priority Worldwide Envelope Int'l
|
54
|
+
# INT.PW.PAK - Priority Worldwide pak Int'l
|
55
|
+
# INT.PW.PARCEL - Priority Worldwide Parcel Int'l
|
56
|
+
# INT.SP.AIR - Small Packet International Air
|
57
|
+
# INT.SP.SURF - Small Packet International Surface
|
58
|
+
# INT.TP - Tracked Package - International
|
59
|
+
SERVICE_CODES = {
|
60
|
+
"DOM.RP" => 'Regular Parcel',
|
61
|
+
"DOM.EP" => 'Expedited Parcel',
|
62
|
+
"DOM.XP" => 'Xpresspost',
|
63
|
+
"DOM.XP.CERT" => 'Xpresspost Certified',
|
64
|
+
"DOM.PC" => 'Priority',
|
65
|
+
"DOM.DT" => 'Delivered Tonight',
|
66
|
+
"DOM.LIB" => 'Library Books',
|
67
|
+
"USA.EP" => 'Expedited Parcel USA',
|
68
|
+
"USA.PW.ENV" => 'Priority Worldwide Envelope USA',
|
69
|
+
"USA.PW.PAK" => 'Priority Worldwide pak USA',
|
70
|
+
"USA.PW.PARCEL" => 'Priority Worldwide Parcel USA',
|
71
|
+
"USA.SP.AIR" => 'Small Packet USA Air',
|
72
|
+
"USA.TP" => 'Tracked Package - USA',
|
73
|
+
"USA.TP.LVM" => 'Tracked Package - USA (LVM) (large volume mailers)',
|
74
|
+
"USA.XP" => 'Xpresspost USA',
|
75
|
+
"INT.XP" => 'Xpresspost international',
|
76
|
+
"INT.IP.AIR" => 'International Parcel Air',
|
77
|
+
"INT.IP.SURF" => 'International Parcel Surface',
|
78
|
+
"INT.PW.ENV" => "Priority Worldwide Envelope Int'l",
|
79
|
+
"INT.PW.PAK" => "Priority Worldwide pak Int'l",
|
80
|
+
"INT.PW.PARCEL" => "Priority Worldwide Parcel Int'l",
|
81
|
+
"INT.SP.AIR" => 'Small Packet International Air',
|
82
|
+
"INT.SP.SURF" => 'Small Packet International Surface',
|
83
|
+
"INT.TP" => 'Tracked Package - International'
|
84
|
+
}
|
85
|
+
|
86
|
+
def initialize(credentials, options = {})
|
87
|
+
@credentials = credentials
|
88
|
+
@authorization = {username: @credentials.username, password: @credentials.password}
|
89
|
+
@customer_number = @credentials.customer_number
|
90
|
+
end
|
91
|
+
|
92
|
+
# def initialize(credentials, options={})
|
93
|
+
# requires!(options, :shipper, :recipient, :package)
|
94
|
+
# @credentials = credentials
|
95
|
+
# @shipper, @recipient, @package, @service_type = options[:shipper], options[:recipient], options[:package], options[:service_type]
|
96
|
+
# @authorization = { username: @credentials.username, password: @credentials.password }
|
97
|
+
# @customer_number = @credentials.customer_number
|
98
|
+
# end
|
99
|
+
|
100
|
+
def process_request
|
101
|
+
raise NotImplementedError, "Override #process_request in subclass"
|
102
|
+
end
|
103
|
+
|
104
|
+
# Sends POST request to CanadaPost API and parse the response,
|
105
|
+
# a class object (Shipment, Rate...) is created if the response is successful
|
106
|
+
def client(url, body, headers)
|
107
|
+
self.class.post(
|
108
|
+
url,
|
109
|
+
body: body,
|
110
|
+
headers: headers,
|
111
|
+
basic_auth: @authorization
|
112
|
+
)
|
113
|
+
end
|
114
|
+
|
115
|
+
def api_url
|
116
|
+
@credentials.mode == "production" ? PRODUCTION_URL : TEST_URL
|
117
|
+
end
|
118
|
+
|
119
|
+
def build_xml
|
120
|
+
raise NotImplementedError, "Override #build_xml in subclass"
|
121
|
+
end
|
122
|
+
|
123
|
+
# Parse response, convert keys to underscore symbols
|
124
|
+
def parse_response(response)
|
125
|
+
response = Hash.from_xml(response.parsed_response.gsub("\n", "")) if response.parsed_response.is_a? String
|
126
|
+
response = sanitize_response_keys(response)
|
127
|
+
end
|
128
|
+
|
129
|
+
def process_response(api_response)
|
130
|
+
shipping_response = {errors: ''}
|
131
|
+
response = parse_response(api_response)
|
132
|
+
if response[:messages].present?
|
133
|
+
response[:messages].each do |key, message|
|
134
|
+
shipping_response[:errors] << message[:description].split('}').last
|
135
|
+
end
|
136
|
+
return shipping_response
|
137
|
+
end
|
138
|
+
|
139
|
+
return response
|
140
|
+
end
|
141
|
+
|
142
|
+
# Recursively sanitizes the response object by cleaning up any hash keys.
|
143
|
+
def sanitize_response_keys(response)
|
144
|
+
if response.is_a?(Hash)
|
145
|
+
response.inject({}) { |result, (key, value)| result[underscorize(key).to_sym] = sanitize_response_keys(value); result }
|
146
|
+
elsif response.is_a?(Array)
|
147
|
+
response.collect { |result| sanitize_response_keys(result) }
|
148
|
+
else
|
149
|
+
response
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module CanadaPost
|
2
|
+
module Request
|
3
|
+
class Manifest < Base
|
4
|
+
|
5
|
+
attr_accessor :phone, :destination, :group_id
|
6
|
+
|
7
|
+
def initialize(credentials, options={})
|
8
|
+
@credentials = credentials
|
9
|
+
if options.present?
|
10
|
+
@phone = options[:phone]
|
11
|
+
@destination = options[:destination]
|
12
|
+
@group_id = options[:group_id]
|
13
|
+
end
|
14
|
+
super(credentials)
|
15
|
+
end
|
16
|
+
|
17
|
+
def process_request
|
18
|
+
api_response = self.class.post(
|
19
|
+
api_url,
|
20
|
+
body: build_xml,
|
21
|
+
headers: manifest_header,
|
22
|
+
basic_auth: @authorization
|
23
|
+
)
|
24
|
+
process_response(api_response)
|
25
|
+
end
|
26
|
+
|
27
|
+
def get_manifest(url)
|
28
|
+
api_response = self.class.get(
|
29
|
+
url,
|
30
|
+
headers: manifest_header,
|
31
|
+
basic_auth: @authorization
|
32
|
+
)
|
33
|
+
process_response(api_response)
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_artifact(url)
|
37
|
+
self.class.get(
|
38
|
+
url,
|
39
|
+
headers: artifact_header,
|
40
|
+
basic_auth: @authorization
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def api_url
|
47
|
+
api_url = @credentials.mode == "production" ? PRODUCTION_URL : TEST_URL
|
48
|
+
api_url += "/rs/#{@credentials.customer_number}/#{@credentials.customer_number}/manifest"
|
49
|
+
end
|
50
|
+
|
51
|
+
def manifest_header
|
52
|
+
{
|
53
|
+
'Content-type' => 'application/vnd.cpc.manifest-v7+xml',
|
54
|
+
'Accept' => 'application/vnd.cpc.manifest-v7+xml'
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
def artifact_header
|
59
|
+
{
|
60
|
+
'Content-type' => 'application/pdf',
|
61
|
+
'Accept' => 'application/pdf'
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
def build_xml
|
66
|
+
ns = "http://www.canadapost.ca/ws/manifest-v7"
|
67
|
+
xsi = 'http://www.w3.org/2001/XMLSchema-instance'
|
68
|
+
builder = Nokogiri::XML::Builder.new do |xml|
|
69
|
+
xml.send(:"transmit-set", :'xmlns:xsi' => xsi, xmlns: ns) {
|
70
|
+
xml.send(:'group-ids') {
|
71
|
+
xml.send(:'group-id', @group_id)
|
72
|
+
}
|
73
|
+
xml.send(:'detailed-manifests', true)
|
74
|
+
xml.send(:'method-of-payment', 'Account')
|
75
|
+
xml.send(:'manifest-address') {
|
76
|
+
add_manifest_details(xml)
|
77
|
+
}
|
78
|
+
}
|
79
|
+
end
|
80
|
+
builder.doc.root.to_xml
|
81
|
+
end
|
82
|
+
|
83
|
+
def add_manifest_details(xml)
|
84
|
+
xml.send(:'manifest-company', @destination[:company])
|
85
|
+
xml.send(:'manifest-name', @destination[:name])
|
86
|
+
xml.send(:'phone-number', @phone)
|
87
|
+
xml.send(:'address-details') {
|
88
|
+
manifest_address(xml, @destination[:address_details])
|
89
|
+
}
|
90
|
+
end
|
91
|
+
|
92
|
+
def manifest_address(xml, params)
|
93
|
+
xml.send(:'address-line-1', params[:address])
|
94
|
+
xml.send(:'city', params[:city])
|
95
|
+
xml.send(:'prov-state', params[:state])
|
96
|
+
if params[:postal_code].present?
|
97
|
+
xml.send(:'postal-zip-code', params[:postal_code].gsub(' ', ''))
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module CanadaPost
|
2
|
+
module Request
|
3
|
+
class Rate < Base
|
4
|
+
|
5
|
+
attr_accessor :shipper, :recipient, :package
|
6
|
+
|
7
|
+
def initialize(credentials, options={})
|
8
|
+
requires!(options, :shipper, :recipient, :package)
|
9
|
+
@credentials = credentials
|
10
|
+
@shipper, @recipient, @package, @service_type = options[:shipper], options[:recipient], options[:package], options[:service_type]
|
11
|
+
super(credentials)
|
12
|
+
end
|
13
|
+
|
14
|
+
def process_request
|
15
|
+
api_response = client(rate_url, build_xml, rate_headers)
|
16
|
+
response = parse_response(api_response)
|
17
|
+
|
18
|
+
if success?(response)
|
19
|
+
rate_reply_details = response[:price_quotes][:price_quote] || []
|
20
|
+
rate_reply_details = [rate_reply_details] if rate_reply_details.is_a? Hash
|
21
|
+
rate_reply_details.map do |rate_reply|
|
22
|
+
CanadaPost::Rate.new(rate_reply)
|
23
|
+
end
|
24
|
+
else
|
25
|
+
error_message =
|
26
|
+
if response[:messages]
|
27
|
+
response[:messages][:message][:description]
|
28
|
+
else
|
29
|
+
'api_response.response'
|
30
|
+
end
|
31
|
+
raise RateError, error_message
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def rate_url
|
38
|
+
api_url + "/rs/ship/price"
|
39
|
+
end
|
40
|
+
|
41
|
+
def rate_headers
|
42
|
+
{
|
43
|
+
'Content-type' => 'application/vnd.cpc.ship.rate-v3+xml',
|
44
|
+
'Accept' => 'application/vnd.cpc.ship.rate-v3+xml'
|
45
|
+
}
|
46
|
+
end
|
47
|
+
|
48
|
+
def build_xml
|
49
|
+
ns = "http://www.canadapost.ca/ws/ship/rate-v3"
|
50
|
+
builder = Nokogiri::XML::Builder.new do |xml|
|
51
|
+
xml.send(:"mailing-scenario", xmlns: ns) {
|
52
|
+
add_requested_shipment(xml)
|
53
|
+
}
|
54
|
+
end
|
55
|
+
builder.doc.root.to_xml
|
56
|
+
end
|
57
|
+
|
58
|
+
def add_requested_shipment(xml)
|
59
|
+
xml.send(:"customer-number", @customer_number)
|
60
|
+
add_package(xml)
|
61
|
+
add_services(xml)
|
62
|
+
add_shipper(xml)
|
63
|
+
add_recipient(xml)
|
64
|
+
end
|
65
|
+
|
66
|
+
def add_shipper(xml)
|
67
|
+
xml.send(:"origin-postal-code", @shipper[:postal_code])
|
68
|
+
end
|
69
|
+
|
70
|
+
def add_recipient(xml)
|
71
|
+
xml.destination {
|
72
|
+
add_destination(xml)
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
76
|
+
def add_package(xml)
|
77
|
+
xml.send(:"parcel-characteristics") {
|
78
|
+
xml.weight @package[:weight][:value]
|
79
|
+
if @package[:dimensions]
|
80
|
+
xml.dimensions {
|
81
|
+
xml.height @package[:dimensions][:height].round(1)
|
82
|
+
xml.width @package[:dimensions][:width].round(1)
|
83
|
+
xml.length @package[:dimensions][:length].round(1)
|
84
|
+
}
|
85
|
+
end
|
86
|
+
if @package[:cylinder]
|
87
|
+
xml.send(:"mailing-tube", @package[:cylinder])
|
88
|
+
end
|
89
|
+
}
|
90
|
+
end
|
91
|
+
|
92
|
+
def add_destination(xml)
|
93
|
+
if @recipient[:country_code] == "CA"
|
94
|
+
xml.domestic {
|
95
|
+
xml.send(:"postal-code", @recipient[:postal_code])
|
96
|
+
}
|
97
|
+
elsif @recipient[:country_code] == "US"
|
98
|
+
xml.send(:"united-states") {
|
99
|
+
xml.send(:"zip-code", @recipient[:postal_code])
|
100
|
+
}
|
101
|
+
else
|
102
|
+
xml.international {
|
103
|
+
xml.send(:"country-code", @recipient[:country_code])
|
104
|
+
}
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def add_services(xml)
|
109
|
+
if @service_type
|
110
|
+
xml.services {
|
111
|
+
xml.send(:"service-code", @service_type)
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def success?(response)
|
117
|
+
response[:price_quotes]
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module CanadaPost
|
2
|
+
module Request
|
3
|
+
class Registration < Base
|
4
|
+
|
5
|
+
def initialize(credentials)
|
6
|
+
@credentials = credentials
|
7
|
+
super(credentials)
|
8
|
+
end
|
9
|
+
|
10
|
+
def get_token
|
11
|
+
api_response = self.class.post(
|
12
|
+
api_url,
|
13
|
+
headers: api_header,
|
14
|
+
basic_auth: @authorization
|
15
|
+
)
|
16
|
+
shipping_response = process_response(api_response)
|
17
|
+
if shipping_response[:token].present?
|
18
|
+
shipping_response[:token]
|
19
|
+
else
|
20
|
+
shipping_response
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def merchant_info(token)
|
25
|
+
merchant_url = api_url + "/#{token}"
|
26
|
+
api_response = self.class.get(
|
27
|
+
merchant_url,
|
28
|
+
headers: api_header,
|
29
|
+
basic_auth: @authorization
|
30
|
+
)
|
31
|
+
process_response(api_response)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def api_header
|
37
|
+
{
|
38
|
+
'Accept-Language' => 'en-CA',
|
39
|
+
'Accept' => 'application/vnd.cpc.registration+xml'
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
def api_url
|
44
|
+
api_url = @credentials.mode == "production" ? PRODUCTION_URL : TEST_URL
|
45
|
+
api_url += "/ot/token"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|