friendly_shipping 0.7.3 → 0.8.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.env.template +4 -0
- data/.env.test +4 -0
- data/CHANGELOG.md +23 -0
- data/friendly_shipping.gemspec +1 -1
- data/lib/friendly_shipping/package_options.rb +4 -0
- data/lib/friendly_shipping/rate.rb +1 -1
- data/lib/friendly_shipping/services/ship_engine/label_customs_options.rb +25 -0
- data/lib/friendly_shipping/services/ship_engine/label_item_options.rb +27 -0
- data/lib/friendly_shipping/services/ship_engine/label_options.rb +5 -1
- data/lib/friendly_shipping/services/ship_engine/label_package_options.rb +2 -1
- data/lib/friendly_shipping/services/ship_engine/serialize_label_shipment.rb +37 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/bad_request.rb +9 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/bad_request_handler.rb +33 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/item_options.rb +31 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/package_options.rb +15 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/parse_carrier_response.rb +49 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/parse_quote_response.rb +72 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/quote_options.rb +34 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/serialize_packages.rb +41 -0
- data/lib/friendly_shipping/services/ship_engine_ltl/serialize_quote_request.rb +109 -0
- data/lib/friendly_shipping/services/ship_engine_ltl.rb +133 -0
- data/lib/friendly_shipping/services/ups/label_item_options.rb +4 -1
- data/lib/friendly_shipping/services/ups/label_package_options.rb +7 -3
- data/lib/friendly_shipping/services/ups/parse_rate_response.rb +3 -3
- data/lib/friendly_shipping/services/ups/parse_shipment_accept_response.rb +1 -1
- data/lib/friendly_shipping/services/ups/serialize_address_snippet.rb +5 -2
- data/lib/friendly_shipping/services/ups/serialize_package_node.rb +11 -1
- data/lib/friendly_shipping/services/ups/serialize_shipment_confirm_request.rb +11 -7
- data/lib/friendly_shipping/services/ups_freight/api_error.rb +2 -0
- data/lib/friendly_shipping/services/ups_freight/generate_location_hash.rb +10 -6
- data/lib/friendly_shipping/services/usps/parse_package_rate.rb +2 -1
- data/lib/friendly_shipping/services/usps/parse_time_in_transit_response.rb +6 -2
- data/lib/friendly_shipping/services/usps/rate_estimate_package_options.rb +4 -8
- data/lib/friendly_shipping/services/usps/shipping_methods.rb +4 -2
- data/lib/friendly_shipping/services/usps_international/parse_package_rate.rb +81 -0
- data/lib/friendly_shipping/services/usps_international/parse_rate_response.rb +86 -0
- data/lib/friendly_shipping/services/usps_international/rate_estimate_options.rb +28 -0
- data/lib/friendly_shipping/services/usps_international/rate_estimate_package_options.rb +45 -0
- data/lib/friendly_shipping/services/usps_international/serialize_rate_request.rb +75 -0
- data/lib/friendly_shipping/services/usps_international/shipping_methods.rb +38 -0
- data/lib/friendly_shipping/services/usps_international.rb +80 -0
- data/lib/friendly_shipping/version.rb +1 -1
- data/lib/friendly_shipping.rb +2 -0
- metadata +24 -3
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry/monads'
|
4
|
+
require 'friendly_shipping/http_client'
|
5
|
+
require 'friendly_shipping/services/ship_engine_ltl/bad_request_handler'
|
6
|
+
require 'friendly_shipping/services/ship_engine_ltl/parse_carrier_response'
|
7
|
+
require 'friendly_shipping/services/ship_engine_ltl/parse_quote_response'
|
8
|
+
require 'friendly_shipping/services/ship_engine_ltl/serialize_packages'
|
9
|
+
require 'friendly_shipping/services/ship_engine_ltl/serialize_quote_request'
|
10
|
+
require 'friendly_shipping/services/ship_engine_ltl/quote_options'
|
11
|
+
require 'friendly_shipping/services/ship_engine_ltl/package_options'
|
12
|
+
require 'friendly_shipping/services/ship_engine_ltl/item_options'
|
13
|
+
|
14
|
+
module FriendlyShipping
|
15
|
+
module Services
|
16
|
+
class ShipEngineLTL
|
17
|
+
include Dry::Monads::Result::Mixin
|
18
|
+
|
19
|
+
API_BASE = "https://api.shipengine.com/v-beta/ltl/"
|
20
|
+
API_PATHS = {
|
21
|
+
connections: "connections",
|
22
|
+
carriers: "carriers",
|
23
|
+
quotes: "quotes"
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
def initialize(
|
27
|
+
token:,
|
28
|
+
test: true,
|
29
|
+
client: FriendlyShipping::HttpClient.new(
|
30
|
+
error_handler: FriendlyShipping::Services::ShipEngineLTL::BadRequestHandler
|
31
|
+
)
|
32
|
+
)
|
33
|
+
@token = token
|
34
|
+
@test = test
|
35
|
+
@client = client
|
36
|
+
end
|
37
|
+
|
38
|
+
# Get configured LTL carriers from ShipEngine
|
39
|
+
#
|
40
|
+
# @return [Result<ApiResult<Array<Carrier>>>] LTL carriers configured in your account
|
41
|
+
def carriers(debug: false)
|
42
|
+
request = FriendlyShipping::Request.new(
|
43
|
+
url: API_BASE + API_PATHS[:carriers],
|
44
|
+
headers: request_headers,
|
45
|
+
debug: debug
|
46
|
+
)
|
47
|
+
client.get(request).bind do |response|
|
48
|
+
ParseCarrierResponse.call(request: request, response: response)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Connect an LTL carrier to ShipEngine
|
53
|
+
#
|
54
|
+
# @param [Hash] credentials The carrier's connection information
|
55
|
+
# @param [String] scac Standard Carrier Alpha Code
|
56
|
+
#
|
57
|
+
# @return [Result<ApiResult<Hash>>] The unique carrier ID assigned by ShipEngine
|
58
|
+
def connect_carrier(credentials, scac, debug: false)
|
59
|
+
request = FriendlyShipping::Request.new(
|
60
|
+
url: API_BASE + API_PATHS[:connections] + "/#{scac}",
|
61
|
+
body: { credentials: credentials }.to_json,
|
62
|
+
headers: request_headers,
|
63
|
+
debug: debug
|
64
|
+
)
|
65
|
+
client.post(request).bind do |response|
|
66
|
+
Success(
|
67
|
+
ApiResult.new(
|
68
|
+
JSON.parse(response.body),
|
69
|
+
original_request: request,
|
70
|
+
original_response: response
|
71
|
+
)
|
72
|
+
)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# Update an existing LTL carrier in ShipEngine
|
77
|
+
#
|
78
|
+
# @param [Hash] credentials The carrier's connection information
|
79
|
+
# @param [String] scac Standard Carrier Alpha Code
|
80
|
+
# @param [String] carrier_id The carrier ID from ShipEngine that you want to update
|
81
|
+
#
|
82
|
+
# @return [Result<ApiResult<Hash>>] The unique carrier ID assigned by ShipEngine
|
83
|
+
def update_carrier(credentials, scac, carrier_id, debug: false)
|
84
|
+
request = FriendlyShipping::Request.new(
|
85
|
+
url: API_BASE + API_PATHS[:connections] + "/#{scac}/#{carrier_id}",
|
86
|
+
body: { credentials: credentials }.to_json,
|
87
|
+
headers: request_headers,
|
88
|
+
debug: debug
|
89
|
+
)
|
90
|
+
client.put(request).bind do |response|
|
91
|
+
Success(
|
92
|
+
ApiResult.new(
|
93
|
+
JSON.parse(response.body),
|
94
|
+
original_request: request,
|
95
|
+
original_response: response
|
96
|
+
)
|
97
|
+
)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Request an LTL price quote from ShipEngine
|
102
|
+
#
|
103
|
+
# @param [String] carrier_id The carrier ID from ShipEngine that you want to quote against
|
104
|
+
# @param [Physical::Shipment] shipment The shipment to quote
|
105
|
+
# @param [FriendlyShipping::Services::ShipEngineLTL::QuoteOptions] options The options for the quote
|
106
|
+
#
|
107
|
+
# @return [Result<ApiResult<Hash>>] The price quote from ShipEngine
|
108
|
+
def request_quote(carrier_id, shipment, options, debug: false)
|
109
|
+
request = FriendlyShipping::Request.new(
|
110
|
+
url: API_BASE + API_PATHS[:quotes] + "/#{carrier_id}",
|
111
|
+
http_method: "POST",
|
112
|
+
body: SerializeQuoteRequest.call(shipment: shipment, options: options).to_json,
|
113
|
+
headers: request_headers,
|
114
|
+
debug: debug
|
115
|
+
)
|
116
|
+
client.post(request).bind do |response|
|
117
|
+
ParseQuoteResponse.call(request: request, response: response)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
attr_reader :token, :test, :client
|
124
|
+
|
125
|
+
def request_headers
|
126
|
+
{
|
127
|
+
content_type: :json,
|
128
|
+
"api-key": token
|
129
|
+
}
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -49,14 +49,17 @@ module FriendlyShipping
|
|
49
49
|
other: 'OTH'
|
50
50
|
}.freeze
|
51
51
|
|
52
|
-
attr_reader :commodity_code
|
52
|
+
attr_reader :commodity_code,
|
53
|
+
:country_of_origin
|
53
54
|
|
54
55
|
def initialize(
|
55
56
|
commodity_code: nil,
|
57
|
+
country_of_origin: nil,
|
56
58
|
product_unit_of_measure: :number,
|
57
59
|
**kwargs
|
58
60
|
)
|
59
61
|
@commodity_code = commodity_code
|
62
|
+
@country_of_origin = country_of_origin
|
60
63
|
@product_unit_of_measure = product_unit_of_measure
|
61
64
|
super(**kwargs)
|
62
65
|
end
|
@@ -11,8 +11,10 @@ module FriendlyShipping
|
|
11
11
|
# values are _reference number values_. Example: `{ reference_numbers: { xn: 'my_reference_1 }`
|
12
12
|
# @option delivery_confirmation [Symbol] Can be set to any key from PACKAGE_DELIVERY_CONFIRMATION_CODES.
|
13
13
|
# Only possible for domestic shipments or shipments between the US and Puerto Rico.
|
14
|
-
# @option shipper_release [Boolean] Indicates that the package may be released by driver without a signature from
|
15
|
-
# consignee. Default: false
|
14
|
+
# @option shipper_release [Boolean] Indicates that the package may be released by driver without a signature from
|
15
|
+
# the consignee. Default: false
|
16
|
+
# @option declared_value [Boolean] When true, declared value (calculated as the sum of all items in the shipment)
|
17
|
+
# will be included in the request. Default: false
|
16
18
|
class LabelPackageOptions < FriendlyShipping::PackageOptions
|
17
19
|
PACKAGE_DELIVERY_CONFIRMATION_CODES = {
|
18
20
|
delivery_confirmation: 1,
|
@@ -20,17 +22,19 @@ module FriendlyShipping
|
|
20
22
|
delivery_confirmation_adult_signature_required: 3
|
21
23
|
}.freeze
|
22
24
|
|
23
|
-
attr_reader :reference_numbers, :shipper_release
|
25
|
+
attr_reader :reference_numbers, :shipper_release, :declared_value
|
24
26
|
|
25
27
|
def initialize(
|
26
28
|
reference_numbers: {},
|
27
29
|
delivery_confirmation: nil,
|
28
30
|
shipper_release: false,
|
31
|
+
declared_value: false,
|
29
32
|
**kwargs
|
30
33
|
)
|
31
34
|
@reference_numbers = reference_numbers
|
32
35
|
@delivery_confirmation = delivery_confirmation
|
33
36
|
@shipper_release = shipper_release
|
37
|
+
@declared_value = declared_value
|
34
38
|
super(**kwargs.merge(item_options_class: LabelItemOptions))
|
35
39
|
end
|
36
40
|
|
@@ -69,11 +69,11 @@ module FriendlyShipping
|
|
69
69
|
def build_packages(rated_shipment)
|
70
70
|
rated_shipment.css('RatedPackage').map do |rated_package|
|
71
71
|
{
|
72
|
-
transportation_charges: ParseMoneyElement.call(rated_package.at('TransportationCharges'))
|
73
|
-
base_service_charge: ParseMoneyElement.call(rated_package.at('BaseServiceCharge'))
|
72
|
+
transportation_charges: ParseMoneyElement.call(rated_package.at('TransportationCharges'))&.last,
|
73
|
+
base_service_charge: ParseMoneyElement.call(rated_package.at('BaseServiceCharge'))&.last,
|
74
74
|
service_options_charges: ParseMoneyElement.call(rated_package.at('ServiceOptionsCharges'))&.last,
|
75
75
|
itemized_charges: extract_charges(rated_package.xpath('ItemizedCharges')),
|
76
|
-
total_charges: ParseMoneyElement.call(rated_package.at('TotalCharges'))
|
76
|
+
total_charges: ParseMoneyElement.call(rated_package.at('TotalCharges'))&.last,
|
77
77
|
negotiated_charges: extract_charges(rated_package.xpath('NegotiatedCharges/ItemizedCharges')),
|
78
78
|
weight: BigDecimal(rated_package.at('Weight').text),
|
79
79
|
billing_weight: BigDecimal(rated_package.at('BillingWeight/Weight').text)
|
@@ -66,7 +66,7 @@ module FriendlyShipping
|
|
66
66
|
|
67
67
|
def get_shipment_cost(shipment_xml)
|
68
68
|
total_charges_element = shipment_xml.at('ShipmentResults/ShipmentCharges/TotalCharges')
|
69
|
-
ParseMoneyElement.call(total_charges_element)
|
69
|
+
ParseMoneyElement.call(total_charges_element)&.last
|
70
70
|
end
|
71
71
|
|
72
72
|
def get_negotiated_rate(shipment_xml)
|
@@ -5,8 +5,11 @@ module FriendlyShipping
|
|
5
5
|
class Ups
|
6
6
|
class SerializeAddressSnippet
|
7
7
|
class << self
|
8
|
-
def call(xml:, location:)
|
9
|
-
if
|
8
|
+
def call(xml:, location:, international: false)
|
9
|
+
if international
|
10
|
+
name = (location.company_name || location.name)[0..34]
|
11
|
+
attention_name = location.name
|
12
|
+
elsif location.company_name # Is this a business address?
|
10
13
|
name = location.company_name[0..34]
|
11
14
|
attention_name = location.name
|
12
15
|
else
|
@@ -10,7 +10,8 @@ module FriendlyShipping
|
|
10
10
|
reference_numbers: {},
|
11
11
|
delivery_confirmation_code: nil,
|
12
12
|
shipper_release: false,
|
13
|
-
transmit_dimensions: true
|
13
|
+
transmit_dimensions: true,
|
14
|
+
declared_value: false
|
14
15
|
)
|
15
16
|
xml.Package do
|
16
17
|
xml.PackagingType do
|
@@ -49,6 +50,15 @@ module FriendlyShipping
|
|
49
50
|
xml.DCISType(delivery_confirmation_code)
|
50
51
|
end
|
51
52
|
end
|
53
|
+
if declared_value
|
54
|
+
xml.DeclaredValue do
|
55
|
+
xml.CurrencyCode('USD')
|
56
|
+
monetary_value = package.items.inject(Money.new(0, 'USD')) do |package_sum, item|
|
57
|
+
package_sum + (item.cost || Money.new(0, 'USD'))
|
58
|
+
end
|
59
|
+
xml.MonetaryValue(monetary_value)
|
60
|
+
end
|
61
|
+
end
|
52
62
|
end
|
53
63
|
|
54
64
|
reference_numbers.each do |reference_code, reference_number|
|
@@ -9,6 +9,7 @@ module FriendlyShipping
|
|
9
9
|
class SerializeShipmentConfirmRequest
|
10
10
|
class << self
|
11
11
|
# Item options (only necessary for international shipping)
|
12
|
+
# ShipTo CompanyName & AttentionName required for international shipping
|
12
13
|
#
|
13
14
|
# @option description [String] A description of the item for the intl. invoice
|
14
15
|
# @option commodity_code [String] Commodity code for the item. See https://www.tariffnumber.com/
|
@@ -41,7 +42,7 @@ module FriendlyShipping
|
|
41
42
|
end
|
42
43
|
|
43
44
|
xml.ShipTo do
|
44
|
-
SerializeShipmentAddressSnippet.call(xml: xml, location: shipment.destination)
|
45
|
+
SerializeShipmentAddressSnippet.call(xml: xml, location: shipment.destination, international: international?(shipment))
|
45
46
|
end
|
46
47
|
|
47
48
|
# Required element. The company whose account is responsible for the label(s).
|
@@ -105,6 +106,8 @@ module FriendlyShipping
|
|
105
106
|
xml.SoldTo do
|
106
107
|
sold_to_location = options.sold_to || shipment.destination
|
107
108
|
SerializeShipmentAddressSnippet.call(xml: xml, location: sold_to_location)
|
109
|
+
xml.AccountNumber(options.sold_to.account_number) if options.sold_to.try(:account_number).present?
|
110
|
+
xml.TaxIdentificationNumber(options.sold_to.tax_id_number) if options.sold_to.try(:tax_id_number).present?
|
108
111
|
end
|
109
112
|
end
|
110
113
|
|
@@ -123,7 +126,7 @@ module FriendlyShipping
|
|
123
126
|
|
124
127
|
contents_description = shipment.packages.flat_map do |package|
|
125
128
|
package.items.map(&:description)
|
126
|
-
end.compact.join(', ')
|
129
|
+
end.compact.uniq.join(', ').slice(0, 50)
|
127
130
|
|
128
131
|
unless contents_description.empty?
|
129
132
|
xml.Description(contents_description)
|
@@ -159,7 +162,8 @@ module FriendlyShipping
|
|
159
162
|
package: package,
|
160
163
|
reference_numbers: reference_numbers,
|
161
164
|
delivery_confirmation_code: delivery_confirmation_code,
|
162
|
-
shipper_release: package_options.shipper_release
|
165
|
+
shipper_release: package_options.shipper_release,
|
166
|
+
declared_value: package_options.declared_value
|
163
167
|
)
|
164
168
|
end
|
165
169
|
end
|
@@ -207,7 +211,7 @@ module FriendlyShipping
|
|
207
211
|
|
208
212
|
def build_billing_info_node(xml, options, bill_to_consignee: false)
|
209
213
|
billing_options = options.billing_options
|
210
|
-
if billing_options.bill_third_party
|
214
|
+
if billing_options.bill_third_party || bill_to_consignee
|
211
215
|
xml.BillThirdParty do
|
212
216
|
node_type = bill_to_consignee ? :BillThirdPartyConsignee : :BillThirdPartyShipper
|
213
217
|
xml.public_send(node_type) do
|
@@ -258,11 +262,11 @@ module FriendlyShipping
|
|
258
262
|
|
259
263
|
xml.Product do
|
260
264
|
cost = reference_item.cost || Money.new(0, 'USD')
|
261
|
-
xml.Description(description)
|
265
|
+
xml.Description(description&.slice(0, 35))
|
262
266
|
xml.CommodityCode(item_options.commodity_code)
|
263
|
-
xml.OriginCountryCode(shipment.origin.country.code)
|
267
|
+
xml.OriginCountryCode(item_options.country_of_origin || shipment.origin.country.code)
|
264
268
|
xml.Unit do
|
265
|
-
xml.Value(cost
|
269
|
+
xml.Value(cost)
|
266
270
|
xml.Number(items.length)
|
267
271
|
xml.UnitOfMeasurement do
|
268
272
|
xml.Code(item_options.product_unit_of_measure_code)
|
@@ -7,17 +7,17 @@ module FriendlyShipping
|
|
7
7
|
class << self
|
8
8
|
def call(location:)
|
9
9
|
{
|
10
|
-
Name: location.company_name.presence || location.name,
|
10
|
+
Name: truncate(location.company_name.presence || location.name),
|
11
11
|
Address: {
|
12
12
|
AddressLine: address_line(location),
|
13
|
-
City: location.city,
|
13
|
+
City: truncate(location.city, length: 29),
|
14
14
|
StateProvinceCode: location.region&.code,
|
15
15
|
PostalCode: location.zip,
|
16
16
|
CountryCode: location.country&.code
|
17
17
|
},
|
18
|
-
AttentionName: location.name,
|
18
|
+
AttentionName: truncate(location.name),
|
19
19
|
Phone: {
|
20
|
-
Number: location.phone
|
20
|
+
Number: truncate(location.phone, length: 14)
|
21
21
|
}.compact.presence
|
22
22
|
}.compact
|
23
23
|
end
|
@@ -29,8 +29,12 @@ module FriendlyShipping
|
|
29
29
|
location.address1,
|
30
30
|
location.address2,
|
31
31
|
location.address3
|
32
|
-
].compact.reject(&:empty?)
|
33
|
-
address_lines.size > 1 ? address_lines : address_lines.first
|
32
|
+
].compact.reject(&:empty?).map { |e| truncate(e) }
|
33
|
+
address_lines.size > 1 ? address_lines : truncate(address_lines.first)
|
34
|
+
end
|
35
|
+
|
36
|
+
def truncate(value, length: 35)
|
37
|
+
value && value[0..(length - 1)]
|
34
38
|
end
|
35
39
|
end
|
36
40
|
end
|
@@ -94,7 +94,8 @@ module FriendlyShipping
|
|
94
94
|
|
95
95
|
rate_value =
|
96
96
|
if commercial_rate_requested_or_rate_is_zero && commercial_rate_available
|
97
|
-
rate_node.at(COMMERCIAL_RATE_TAG)&.text
|
97
|
+
commercial_rate = rate_node.at(COMMERCIAL_RATE_TAG)&.text.to_d
|
98
|
+
commercial_rate.zero? ? rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d : commercial_rate
|
98
99
|
else
|
99
100
|
rate_node.at(RATE_TAG).text.to_d
|
100
101
|
end
|
@@ -106,10 +106,12 @@ module FriendlyShipping
|
|
106
106
|
# “0” = All Mail Classes
|
107
107
|
# “1” = Priority Mail Express
|
108
108
|
# “2” = Priority Mail
|
109
|
-
# “3” = First
|
109
|
+
# “3” = First-Class - replaced by Ground Advantage (up to 15.999 oz)
|
110
110
|
# “4” = Marketing Mail
|
111
111
|
# “5” = Periodicals
|
112
112
|
# “6” = Package Services
|
113
|
+
# “7” = Parcel Select Ground - replaced by Ground Advantage (1-70 lbs)
|
114
|
+
# “9” = Ground Advantage (1-70 lbs)
|
113
115
|
#
|
114
116
|
# However, no shipping methods really map to "Marketing Mail" or "Periodicals".
|
115
117
|
# This will likely be somewhat more work in the future.
|
@@ -117,7 +119,9 @@ module FriendlyShipping
|
|
117
119
|
'1' => 'Priority Mail Express',
|
118
120
|
'2' => 'Priority Mail',
|
119
121
|
'3' => 'First-Class',
|
120
|
-
'6' => 'Package Services'
|
122
|
+
'6' => 'Package Services',
|
123
|
+
'7' => 'Parcel Select Ground',
|
124
|
+
'9' => 'Ground Advantage'
|
121
125
|
}.freeze
|
122
126
|
|
123
127
|
# This code carries a few details about the shipment:
|
@@ -8,8 +8,10 @@ module FriendlyShipping
|
|
8
8
|
#
|
9
9
|
# @param [Symbol] box_name The type of box we want to get rates for. Has to be one of the keys
|
10
10
|
# of FriendlyShipping::Services::Usps::CONTAINERS.
|
11
|
-
# @param [
|
12
|
-
# @param [
|
11
|
+
# @param [Boolean] transmit_dimensions Indicate whether the reuqest should include the package dimensionals.
|
12
|
+
# @param [Boolean] rectangular Indicate whether the package is rectangular.
|
13
|
+
# @param [Boolean] return_dimensional_weight Indicate whether the response should include dimensional weight.
|
14
|
+
# @param [Boolean] return_fees Indicate whether the response should include fees.
|
13
15
|
class Usps
|
14
16
|
class RateEstimatePackageOptions < FriendlyShipping::PackageOptions
|
15
17
|
attr_reader :box_name,
|
@@ -55,12 +57,6 @@ module FriendlyShipping
|
|
55
57
|
service_code << 'COMMERCIAL' if commercial_pricing
|
56
58
|
service_code.join(' ')
|
57
59
|
end
|
58
|
-
|
59
|
-
private
|
60
|
-
|
61
|
-
def value_or_default(key, default, kwargs)
|
62
|
-
kwargs.key?(key) ? kwargs.delete(key) : default
|
63
|
-
end
|
64
60
|
end
|
65
61
|
end
|
66
62
|
end
|
@@ -35,11 +35,13 @@ module FriendlyShipping
|
|
35
35
|
hold_for_pickup: '2',
|
36
36
|
sunday_holiday_delivery: '23'
|
37
37
|
},
|
38
|
-
priority_mail_cubic: '999'
|
38
|
+
priority_mail_cubic: '999',
|
39
|
+
ground_advantage: '1058'
|
39
40
|
}.freeze
|
40
41
|
|
41
42
|
SHIPPING_METHODS = [
|
42
43
|
['FIRST CLASS', 'First-Class'],
|
44
|
+
['GROUND ADVANTAGE', 'Ground Advantage', CLASS_IDS[:ground_advantage]],
|
43
45
|
['PACKAGE SERVICES', 'Package Services'],
|
44
46
|
['PRIORITY', 'Priority Mail'],
|
45
47
|
['PRIORITY MAIL EXPRESS', 'Priority Mail Express', CLASS_IDS[:priority_mail_express].values],
|
@@ -55,7 +57,7 @@ module FriendlyShipping
|
|
55
57
|
service_code: code,
|
56
58
|
domestic: true,
|
57
59
|
international: false,
|
58
|
-
data: { class_ids: class_ids }
|
60
|
+
data: { class_ids: Array(class_ids) }
|
59
61
|
)
|
60
62
|
end.freeze
|
61
63
|
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class UspsInternational
|
6
|
+
class ParsePackageRate
|
7
|
+
# USPS returns all the info about a rate in a long string with a bit of gibberish.
|
8
|
+
ESCAPING_AND_SYMBOLS = /<\S*>/.freeze
|
9
|
+
|
10
|
+
# At the beginning of the long String, USPS keeps a copy of its own name. We know we're dealing with
|
11
|
+
# them though, so we can filter that out, too.
|
12
|
+
LEADING_USPS = /^USPS /.freeze
|
13
|
+
|
14
|
+
# This combines all the things we want to filter out.
|
15
|
+
SERVICE_NAME_SUBSTITUTIONS = /#{ESCAPING_AND_SYMBOLS}|#{LEADING_USPS}/.freeze
|
16
|
+
|
17
|
+
# Often we get a multitude of rates for the same service given some combination of
|
18
|
+
# Box type and (see below) and "Hold for Pickup" service. This creates a regular expression
|
19
|
+
# with groups named after the keys from the `Usps::CONTAINERS` constant.
|
20
|
+
# Unfortunately, the keys don't correspond directly to the codes we use when serializing the
|
21
|
+
# request.
|
22
|
+
# The tags used in the rate node that we get information from.
|
23
|
+
SERVICE_CODE_TAG = 'ID'
|
24
|
+
SERVICE_NAME_TAG = 'SvcDescription'
|
25
|
+
RATE_TAG = 'Postage'
|
26
|
+
COMMERCIAL_RATE_TAG = 'CommercialPostage'
|
27
|
+
COMMERCIAL_PLUS_RATE_TAG = 'CommercialPlusPostage'
|
28
|
+
CURRENCY = Money::Currency.new('USD').freeze
|
29
|
+
|
30
|
+
class << self
|
31
|
+
def call(rate_node, package, package_options)
|
32
|
+
# "A mail class identifier for the postage returned. Not necessarily unique within a <Package/>."
|
33
|
+
# (from the USPS docs). We save this on the data Hash, but do not use it for identifying shipping methods.
|
34
|
+
service_code = rate_node.attributes[SERVICE_CODE_TAG].value
|
35
|
+
|
36
|
+
# The long string discussed above.
|
37
|
+
service_name = rate_node.at(SERVICE_NAME_TAG).text
|
38
|
+
|
39
|
+
delivery_guarantee = rate_node.at('GuaranteeAvailability')&.text
|
40
|
+
delivery_commitment = rate_node.at('SvcCommitments')&.text
|
41
|
+
|
42
|
+
# Clean up the long string
|
43
|
+
service_name.gsub!(SERVICE_NAME_SUBSTITUTIONS, '')
|
44
|
+
|
45
|
+
commercial_rate_requested_or_rate_is_zero = package_options.commercial_pricing || rate_node.at(RATE_TAG).text.to_d.zero?
|
46
|
+
commercial_rate_available = rate_node.at(COMMERCIAL_RATE_TAG) || rate_node.at(COMMERCIAL_PLUS_RATE_TAG)
|
47
|
+
|
48
|
+
rate_value =
|
49
|
+
if commercial_rate_requested_or_rate_is_zero && commercial_rate_available
|
50
|
+
commercial_rate = rate_node.at(COMMERCIAL_RATE_TAG)&.text.to_d
|
51
|
+
commercial_rate.zero? ? rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d : commercial_rate
|
52
|
+
else
|
53
|
+
rate_node.at(RATE_TAG).text.to_d
|
54
|
+
end
|
55
|
+
|
56
|
+
# The rate expressed as a RubyMoney objext
|
57
|
+
rate = Money.new(rate_value * CURRENCY.subunit_to_unit, CURRENCY)
|
58
|
+
|
59
|
+
# Which shipping method does this rate belong to? We first try to match a rate to a shipping method
|
60
|
+
shipping_method = SHIPPING_METHODS.find { |sm| sm.service_code == service_code }
|
61
|
+
|
62
|
+
# Combine all the gathered information in a FriendlyShipping::Rate object.
|
63
|
+
# Careful: This rate is only for one package within the shipment, and we get multiple
|
64
|
+
# rates per package for the different shipping method/box/hold for pickup combinations.
|
65
|
+
FriendlyShipping::Rate.new(
|
66
|
+
shipping_method: shipping_method,
|
67
|
+
amounts: { package.id => rate },
|
68
|
+
data: {
|
69
|
+
package: package,
|
70
|
+
delivery_commitment: delivery_commitment,
|
71
|
+
delivery_guarantee: delivery_guarantee,
|
72
|
+
full_mail_service: service_name,
|
73
|
+
service_code: service_code,
|
74
|
+
}
|
75
|
+
)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/services/usps/parse_xml_response'
|
4
|
+
require 'friendly_shipping/services/usps_international/parse_package_rate'
|
5
|
+
|
6
|
+
module FriendlyShipping
|
7
|
+
module Services
|
8
|
+
class UspsInternational
|
9
|
+
class ParseRateResponse
|
10
|
+
class BoxNotFoundError < StandardError; end
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Parse a response from USPS' international rating API
|
14
|
+
#
|
15
|
+
# @param [FriendlyShipping::Request] request The request that was used to obtain this Response
|
16
|
+
# @param [FriendlyShipping::Response] response The response that USPS returned
|
17
|
+
# @param [Physical::Shipment] shipment The shipment object we're trying to get results for
|
18
|
+
# @param [FriendlyShipping::Services::UspsInternational::RateEstimateOptions] options The options we sent with this request
|
19
|
+
# @return [Result<ApiResult<Array<FriendlyShipping::Rate>>>] When successfully parsing, an array of rates in a Success Monad.
|
20
|
+
def call(request:, response:, shipment:, options:)
|
21
|
+
# Filter out error responses and directly return a failure
|
22
|
+
parsing_result = FriendlyShipping::Services::Usps::ParseXMLResponse.call(
|
23
|
+
request: request,
|
24
|
+
response: response,
|
25
|
+
expected_root_tag: 'IntlRateV2Response'
|
26
|
+
)
|
27
|
+
parsing_result.fmap do |xml|
|
28
|
+
# Get all the possible rates for each package
|
29
|
+
rates_by_package = rates_from_response_node(xml, shipment, options)
|
30
|
+
|
31
|
+
rates = SHIPPING_METHODS.map do |shipping_method|
|
32
|
+
# For every package ...
|
33
|
+
matching_rates = rates_by_package.map do |_package, package_rates|
|
34
|
+
# ... choose the rate that matches the shipping method for this package
|
35
|
+
package_rates.select { |r| r.shipping_method == shipping_method }.first
|
36
|
+
end.compact # Some shipping rates are not available for every shipping method.
|
37
|
+
|
38
|
+
# in this case, go to the next shipping method.
|
39
|
+
next if matching_rates.empty?
|
40
|
+
|
41
|
+
# return one rate for all packages with the amount keys being the package IDs.
|
42
|
+
FriendlyShipping::Rate.new(
|
43
|
+
amounts: matching_rates.map(&:amounts).reduce({}, :merge),
|
44
|
+
shipping_method: shipping_method,
|
45
|
+
data: matching_rates.first.data
|
46
|
+
)
|
47
|
+
end.compact
|
48
|
+
|
49
|
+
ApiResult.new(
|
50
|
+
rates,
|
51
|
+
original_request: request,
|
52
|
+
original_response: response
|
53
|
+
)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
PACKAGE_NODE_XPATH = '//Package'
|
60
|
+
SERVICE_NODE_NAME = 'Service'
|
61
|
+
|
62
|
+
# Iterate over all packages and parse the rates for each package
|
63
|
+
#
|
64
|
+
# @param [Nokogiri::XML::Node] xml The XML document containing packages and rates
|
65
|
+
# @param [Physical::Shipment] shipment The shipment we're trying to get rates for
|
66
|
+
#
|
67
|
+
# @return [Hash<Physical::Package => Array<FriendlyShipping::Rate>>]
|
68
|
+
def rates_from_response_node(xml, shipment, options)
|
69
|
+
xml.xpath(PACKAGE_NODE_XPATH).each_with_object({}) do |package_node, result|
|
70
|
+
package_id = package_node['ID']
|
71
|
+
corresponding_package = shipment.packages[package_id.to_i]
|
72
|
+
package_options = options.options_for_package(corresponding_package)
|
73
|
+
# There should always be a package in the original shipment that corresponds to the package ID
|
74
|
+
# in the USPS response.
|
75
|
+
raise BoxNotFoundError if corresponding_package.nil?
|
76
|
+
|
77
|
+
result[corresponding_package] = package_node.xpath(SERVICE_NODE_NAME).map do |service_node|
|
78
|
+
ParsePackageRate.call(service_node, corresponding_package, package_options)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/services/usps_international/rate_estimate_package_options'
|
4
|
+
|
5
|
+
module FriendlyShipping
|
6
|
+
module Services
|
7
|
+
# Option container for rating a shipment via USPS
|
8
|
+
#
|
9
|
+
# Context: The shipment object we're trying to get results for
|
10
|
+
# USPS returns rates on a package-by-package basis, so the options for obtaining rates are
|
11
|
+
# set on the [FriendlyShipping/RateEstimateObject] hash. The possible options are:
|
12
|
+
|
13
|
+
# @param [Physical::ShippingMethod] shipping_method The shipping method ("service" in USPS parlance) we want
|
14
|
+
# to get rates for.
|
15
|
+
# @param [Boolean] commercial_pricing Whether we prefer commercial pricing results or retail results
|
16
|
+
# @param [Boolean] hold_for_pickup Whether we want a rate with Hold For Pickup Service
|
17
|
+
class UspsInternational
|
18
|
+
class RateEstimateOptions < FriendlyShipping::ShipmentOptions
|
19
|
+
def initialize(
|
20
|
+
package_options_class: FriendlyShipping::Services::UspsInternational::RateEstimatePackageOptions,
|
21
|
+
**kwargs
|
22
|
+
)
|
23
|
+
super(**kwargs.merge(package_options_class: package_options_class))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|