friendly_shipping 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|