friendly_shipping 0.7.3 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -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/ups/label_item_options.rb +4 -1
- 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_shipment_confirm_request.rb +6 -5
- data/lib/friendly_shipping/services/ups_freight/api_error.rb +2 -0
- data/lib/friendly_shipping/services/usps/rate_estimate_package_options.rb +4 -8
- data/lib/friendly_shipping/services/usps_international/parse_package_rate.rb +80 -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 +1 -0
- metadata +12 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 269d5f03467b9fc61a71f0585c7ff434156465e008d01e26e319666a15b13fb1
|
4
|
+
data.tar.gz: 006fe64631e6b7eef89c796d5474dafb71896e7eff8d081572c6a85a641fb537
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b0ca75ff4a78b69b1da507dbc5fc9872629217f5a28fd37ea9645078bbda1b592beb74eb71b44b69c8e4649b878e1e715060ad545f8b8b8bdd19a3b256996112
|
7
|
+
data.tar.gz: 1945415f57c1ab8d22bf80e582b22938235965c1db3981c78ef3c6c76087507ae4c5a9e3db7a2a1e27899a9e90ef2a3d97b6cee24673b51698df0c07747aae49
|
data/CHANGELOG.md
CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
6
6
|
|
7
|
+
## [0.8.0] - 2023-04-18
|
8
|
+
- Rails 7 support: Fix deprecation warning about ActiveSupport#sum (#164)
|
9
|
+
- UPS Service: Truncate product descriptions (#163)
|
10
|
+
- TForce Service: Handle Timeouts gracefully (#162)
|
11
|
+
- UPS Service: Support per-item origin countries for paperless invoices (#161)
|
12
|
+
- USPS Service: Fix for currency formatting when shipping internationally (#160)
|
13
|
+
- ShipEngine Service: Add support for customs information (#159)
|
14
|
+
- UPS Service: Require both name and attention name for international shipping (#158)
|
15
|
+
- UPS Service: Allow third-party billing for taxes and fees (#156)
|
16
|
+
- USPS: New service for international shipping (#155)
|
17
|
+
- UPS Service: Parse missing package charges (#154)
|
18
|
+
|
7
19
|
## [0.7.3] - 2023-01-24
|
8
20
|
- UPS Service: Record USPS tracking code (#153)
|
9
21
|
|
data/friendly_shipping.gemspec
CHANGED
@@ -7,7 +7,7 @@ require "friendly_shipping/version"
|
|
7
7
|
Gem::Specification.new do |spec|
|
8
8
|
spec.name = "friendly_shipping"
|
9
9
|
spec.version = FriendlyShipping::VERSION
|
10
|
-
spec.authors = ["Martin Meyerhoff"]
|
10
|
+
spec.authors = ["Martin Meyerhoff", "Matthew Bass"]
|
11
11
|
spec.email = ["mamhoff@gmail.com"]
|
12
12
|
|
13
13
|
spec.summary = "An integration layer for shipping services"
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class ShipEngine
|
6
|
+
# Represents customs options for obtaining international shipment labels.
|
7
|
+
# @option contents [String] The contents of the shipment.
|
8
|
+
# Valid values are: gift, merchandise, returned_goods, documents, sample
|
9
|
+
# @option non_delivery [String] Indicates what should be done if the shipment cannot be delivered.
|
10
|
+
# Valid values are: treat_as_abandoned, return_to_sender
|
11
|
+
class LabelCustomsOptions
|
12
|
+
attr_reader :contents,
|
13
|
+
:non_delivery
|
14
|
+
|
15
|
+
def initialize(
|
16
|
+
contents: "merchandise",
|
17
|
+
non_delivery: "return_to_sender"
|
18
|
+
)
|
19
|
+
@contents = contents
|
20
|
+
@non_delivery = non_delivery
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/item_options'
|
4
|
+
|
5
|
+
module FriendlyShipping
|
6
|
+
module Services
|
7
|
+
class ShipEngine
|
8
|
+
# Represents item options for obtaining shipment labels.
|
9
|
+
# @option commodity_code [String] This item's HS or NMFC code for international shipments.
|
10
|
+
# @option country_of_origin [String] This item's country of origin for international shipments.
|
11
|
+
class LabelItemOptions < FriendlyShipping::ItemOptions
|
12
|
+
attr_reader :commodity_code,
|
13
|
+
:country_of_origin
|
14
|
+
|
15
|
+
def initialize(
|
16
|
+
commodity_code: nil,
|
17
|
+
country_of_origin: nil,
|
18
|
+
**kwargs
|
19
|
+
)
|
20
|
+
@commodity_code = commodity_code
|
21
|
+
@country_of_origin = country_of_origin
|
22
|
+
super(**kwargs)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'friendly_shipping/shipment_options'
|
4
|
+
require 'friendly_shipping/services/ship_engine/label_customs_options'
|
4
5
|
|
5
6
|
module FriendlyShipping
|
6
7
|
module Services
|
@@ -17,19 +18,22 @@ module FriendlyShipping
|
|
17
18
|
attr_reader :shipping_method,
|
18
19
|
:label_download_type,
|
19
20
|
:label_format,
|
20
|
-
:label_image_id
|
21
|
+
:label_image_id,
|
22
|
+
:customs_options
|
21
23
|
|
22
24
|
def initialize(
|
23
25
|
shipping_method:,
|
24
26
|
label_download_type: :url,
|
25
27
|
label_format: :pdf,
|
26
28
|
label_image_id: nil,
|
29
|
+
customs_options: LabelCustomsOptions.new,
|
27
30
|
**kwargs
|
28
31
|
)
|
29
32
|
@shipping_method = shipping_method
|
30
33
|
@label_download_type = label_download_type
|
31
34
|
@label_format = label_format
|
32
35
|
@label_image_id = label_image_id
|
36
|
+
@customs_options = customs_options
|
33
37
|
super(**kwargs.merge(package_options_class: LabelPackageOptions))
|
34
38
|
end
|
35
39
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'friendly_shipping/package_options'
|
4
|
+
require 'friendly_shipping/services/ship_engine/label_item_options'
|
4
5
|
|
5
6
|
module FriendlyShipping
|
6
7
|
module Services
|
@@ -20,7 +21,7 @@ module FriendlyShipping
|
|
20
21
|
def initialize(package_code: nil, messages: [], **kwargs)
|
21
22
|
@package_code = package_code
|
22
23
|
@messages = messages
|
23
|
-
super(**kwargs)
|
24
|
+
super(**kwargs.merge(item_options_class: LabelItemOptions))
|
24
25
|
end
|
25
26
|
end
|
26
27
|
end
|
@@ -21,6 +21,10 @@ module FriendlyShipping
|
|
21
21
|
shipment_hash[:shipment][:carrier_id] = options.shipping_method.carrier.id
|
22
22
|
end
|
23
23
|
|
24
|
+
if international?(shipment)
|
25
|
+
shipment_hash[:shipment][:customs] = serialize_customs(shipment.packages, options)
|
26
|
+
end
|
27
|
+
|
24
28
|
if test
|
25
29
|
shipment_hash[:test_label] = true
|
26
30
|
end
|
@@ -68,6 +72,35 @@ module FriendlyShipping
|
|
68
72
|
end
|
69
73
|
end
|
70
74
|
|
75
|
+
def serialize_customs(packages, options)
|
76
|
+
{
|
77
|
+
contents: options.customs_options.contents,
|
78
|
+
non_delivery: options.customs_options.non_delivery,
|
79
|
+
customs_items: serialize_customs_items(packages, options)
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
def serialize_customs_items(packages, options)
|
84
|
+
packages.map do |package|
|
85
|
+
package.items.group_by(&:sku).map do |sku, items|
|
86
|
+
reference_item = items.first
|
87
|
+
package_options = options.options_for_package(package)
|
88
|
+
item_options = package_options.options_for_item(reference_item)
|
89
|
+
{
|
90
|
+
sku: sku,
|
91
|
+
description: reference_item.description,
|
92
|
+
quantity: items.count,
|
93
|
+
value: {
|
94
|
+
amount: reference_item.cost.to_d,
|
95
|
+
currency: reference_item.cost.currency.to_s
|
96
|
+
},
|
97
|
+
harmonized_tariff_code: item_options.commodity_code,
|
98
|
+
country_of_origin: item_options.country_of_origin
|
99
|
+
}
|
100
|
+
end
|
101
|
+
end.flatten
|
102
|
+
end
|
103
|
+
|
71
104
|
def serialize_weight(weight)
|
72
105
|
ounces = weight.convert_to(:ounce).value.to_f
|
73
106
|
{
|
@@ -78,6 +111,10 @@ module FriendlyShipping
|
|
78
111
|
}
|
79
112
|
}
|
80
113
|
end
|
114
|
+
|
115
|
+
def international?(shipment)
|
116
|
+
shipment.origin.country != shipment.destination.country
|
117
|
+
end
|
81
118
|
end
|
82
119
|
end
|
83
120
|
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
|
@@ -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
|
@@ -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).
|
@@ -123,7 +124,7 @@ module FriendlyShipping
|
|
123
124
|
|
124
125
|
contents_description = shipment.packages.flat_map do |package|
|
125
126
|
package.items.map(&:description)
|
126
|
-
end.compact.join(', ')
|
127
|
+
end.compact.join(', ').slice(0, 50)
|
127
128
|
|
128
129
|
unless contents_description.empty?
|
129
130
|
xml.Description(contents_description)
|
@@ -207,7 +208,7 @@ module FriendlyShipping
|
|
207
208
|
|
208
209
|
def build_billing_info_node(xml, options, bill_to_consignee: false)
|
209
210
|
billing_options = options.billing_options
|
210
|
-
if billing_options.bill_third_party
|
211
|
+
if billing_options.bill_third_party || bill_to_consignee
|
211
212
|
xml.BillThirdParty do
|
212
213
|
node_type = bill_to_consignee ? :BillThirdPartyConsignee : :BillThirdPartyShipper
|
213
214
|
xml.public_send(node_type) do
|
@@ -258,9 +259,9 @@ module FriendlyShipping
|
|
258
259
|
|
259
260
|
xml.Product do
|
260
261
|
cost = reference_item.cost || Money.new(0, 'USD')
|
261
|
-
xml.Description(description)
|
262
|
+
xml.Description(description&.slice(0, 35))
|
262
263
|
xml.CommodityCode(item_options.commodity_code)
|
263
|
-
xml.OriginCountryCode(shipment.origin.country.code)
|
264
|
+
xml.OriginCountryCode(item_options.country_of_origin || shipment.origin.country.code)
|
264
265
|
xml.Unit do
|
265
266
|
xml.Value(cost * items.length)
|
266
267
|
xml.Number(items.length)
|
@@ -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
|
@@ -0,0 +1,80 @@
|
|
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
|
+
rate_node.at(COMMERCIAL_RATE_TAG)&.text&.to_d || rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d
|
51
|
+
else
|
52
|
+
rate_node.at(RATE_TAG).text.to_d
|
53
|
+
end
|
54
|
+
|
55
|
+
# The rate expressed as a RubyMoney objext
|
56
|
+
rate = Money.new(rate_value * CURRENCY.subunit_to_unit, CURRENCY)
|
57
|
+
|
58
|
+
# Which shipping method does this rate belong to? We first try to match a rate to a shipping method
|
59
|
+
shipping_method = SHIPPING_METHODS.find { |sm| sm.service_code == service_code }
|
60
|
+
|
61
|
+
# Combine all the gathered information in a FriendlyShipping::Rate object.
|
62
|
+
# Careful: This rate is only for one package within the shipment, and we get multiple
|
63
|
+
# rates per package for the different shipping method/box/hold for pickup combinations.
|
64
|
+
FriendlyShipping::Rate.new(
|
65
|
+
shipping_method: shipping_method,
|
66
|
+
amounts: { package.id => rate },
|
67
|
+
data: {
|
68
|
+
package: package,
|
69
|
+
delivery_commitment: delivery_commitment,
|
70
|
+
delivery_guarantee: delivery_guarantee,
|
71
|
+
full_mail_service: service_name,
|
72
|
+
service_code: service_code,
|
73
|
+
}
|
74
|
+
)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
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
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/services/ups/rate_estimate_package_options'
|
4
|
+
|
5
|
+
module FriendlyShipping
|
6
|
+
module Services
|
7
|
+
# Options for one package when rating
|
8
|
+
#
|
9
|
+
# @param [Symbol] box_name The type of box we want to get rates for.
|
10
|
+
# @param [Boolean] commercial_pricing Indicate whether the response should include commercial pricing rates.
|
11
|
+
# @param [Boolean] commercial_plus_pricing Indicate whether the response should include commercial pluse pricing rates.
|
12
|
+
# @param [Symbol] container_code Indicate the type of container of the package.
|
13
|
+
# @param [Symbol] mail_type Indicate the type of mail to estimate rates.
|
14
|
+
# @param [Boolean] rectangular Indicate whether the package is rectangular.
|
15
|
+
# @param [FriendlyShipping::ShippingMethod] shipping_method Describe the requested shipping method.
|
16
|
+
# @param [Symbol] transmit_dimensions Indicate whether the request should include the package dimensionals.
|
17
|
+
class UspsInternational
|
18
|
+
class RateEstimatePackageOptions < FriendlyShipping::PackageOptions
|
19
|
+
attr_reader :box_name,
|
20
|
+
:commercial_pricing,
|
21
|
+
:commercial_plus_pricing,
|
22
|
+
:container,
|
23
|
+
:mail_type,
|
24
|
+
:rectangular,
|
25
|
+
:shipping_method,
|
26
|
+
:transmit_dimensions
|
27
|
+
|
28
|
+
def initialize(**kwargs)
|
29
|
+
container_code = value_or_default(:container, :variable, kwargs) || :variable
|
30
|
+
mail_type_code = value_or_default(:mail_type, :all, kwargs) || :all
|
31
|
+
|
32
|
+
@box_name = value_or_default(:box_name, :variable, kwargs)
|
33
|
+
@commercial_pricing = value_or_default(:commercial_pricing, false, kwargs) ? 'Y' : 'N'
|
34
|
+
@commercial_plus_pricing = value_or_default(:commercial_plus_pricing, false, kwargs) ? 'Y' : 'N'
|
35
|
+
@container = CONTAINERS.fetch(container_code)
|
36
|
+
@mail_type = MAIL_TYPES.fetch(mail_type_code)
|
37
|
+
@rectangular = @container.eql?("ROLL") ? false : value_or_default(:rectangular, true, kwargs)
|
38
|
+
@shipping_method = kwargs.delete(:shipping_method)
|
39
|
+
@transmit_dimensions = value_or_default(:transmit_dimensions, true, kwargs)
|
40
|
+
super(**kwargs)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/services/usps/machinable_package'
|
4
|
+
|
5
|
+
module FriendlyShipping
|
6
|
+
module Services
|
7
|
+
class UspsInternational
|
8
|
+
class SerializeRateRequest
|
9
|
+
class << self
|
10
|
+
# @param [Physical::Shipment] shipment The shipment we want to get rates for
|
11
|
+
# shipment.packages[0].properties[:box_name] Can be :variable or a
|
12
|
+
# flat rate container defined in CONTAINERS.
|
13
|
+
# @param [String] login The USPS login code
|
14
|
+
# @param [FriendlyShipping::Services::UspsInternational::RateEstimateOptions] options The options
|
15
|
+
# object to use with this request.
|
16
|
+
# @return Array<[FriendlyShipping::Rate]> A set of Rates that this package may be sent with
|
17
|
+
def call(shipment:, login:, options:)
|
18
|
+
xml_builder = Nokogiri::XML::Builder.new do |xml|
|
19
|
+
xml.IntlRateV2Request('USERID' => login) do
|
20
|
+
xml.Revision("2")
|
21
|
+
shipment.packages.each_with_index do |package, index|
|
22
|
+
xml.Package('ID' => index) do
|
23
|
+
xml.Pounds(pounds_for(package))
|
24
|
+
xml.Ounces(ounces_for(package))
|
25
|
+
xml.Machinable(machinable(package))
|
26
|
+
package_options = options.options_for_package(package)
|
27
|
+
xml.MailType(package_options.mail_type)
|
28
|
+
xml.ValueOfContents(package.items_value)
|
29
|
+
xml.Country(shipment.destination.country)
|
30
|
+
xml.Container(package_options.container)
|
31
|
+
if package_options.transmit_dimensions && package_options.container == 'VARIABLE'
|
32
|
+
xml.Width("%<width>0.2f" % { width: package.width.convert_to(:inches).value.to_f })
|
33
|
+
xml.Length("%<length>0.2f" % { length: package.length.convert_to(:inches).value.to_f })
|
34
|
+
xml.Height("%<height>0.2f" % { height: package.height.convert_to(:inches).value.to_f })
|
35
|
+
|
36
|
+
# When girth is present, the package is treated as non-rectangular
|
37
|
+
# when calculating dimensional weight. This results in a smaller
|
38
|
+
# dimensional weight than a rectangular package would have.
|
39
|
+
unless package_options.rectangular
|
40
|
+
xml.Girth("%<girth>0.2f" % { girth: girth(package) })
|
41
|
+
end
|
42
|
+
xml.CommercialFlag(package_options.commercial_pricing)
|
43
|
+
xml.CommercialPlusFlag(package_options.commercial_plus_pricing)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
xml_builder.to_xml
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def machinable(package)
|
55
|
+
FriendlyShipping::Services::Usps::MachinablePackage.new(package).machinable? ? 'true' : 'false'
|
56
|
+
end
|
57
|
+
|
58
|
+
def ounces_for(package)
|
59
|
+
ounces = package.weight.convert_to(:ounces).value.to_f.round(2).ceil
|
60
|
+
ounces == 16 ? 15.999 : [ounces, 1].max
|
61
|
+
end
|
62
|
+
|
63
|
+
def pounds_for(package)
|
64
|
+
package.weight.convert_to(:pounds).value.to_f.round(2).ceil
|
65
|
+
end
|
66
|
+
|
67
|
+
def girth(package)
|
68
|
+
width, length = package.dimensions.sort.first(2)
|
69
|
+
(width.scale(2) + length.scale(2)).convert_to(:inches).value.to_f
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class UspsInternational
|
6
|
+
SHIPPING_METHODS = [
|
7
|
+
["1", "Priority Mail Express International"],
|
8
|
+
["2", "Priority Mail International"],
|
9
|
+
["4", "Global Express Guaranteed; (GXG)"],
|
10
|
+
["5", "Global Express Guaranteed; Document"],
|
11
|
+
["6", "Global Express Guarantee; Non-Document Rectangular"],
|
12
|
+
["7", "Global Express Guaranteed; Non-Document Non-Rectangular"],
|
13
|
+
["8", "Priority Mail International; Flat Rate Envelope"],
|
14
|
+
["9", "Priority Mail International; Medium Flat Rate Box"],
|
15
|
+
["10", "Priority Mail Express International; Flat Rate Envelope"],
|
16
|
+
["11", "Priority Mail International; Large Flat Rate Box"],
|
17
|
+
["12", "USPS GXG; Envelopes"],
|
18
|
+
["13", "First-Class Mail; International Letter"],
|
19
|
+
["14", "First-Class Mail; International Large Envelope"],
|
20
|
+
["15", "First-Class Package International Service"],
|
21
|
+
["16", "Priority Mail International; Small Flat Rate Box"],
|
22
|
+
["17", "Priority Mail Express International; Legal Flat Rate Envelope"],
|
23
|
+
["18", "Priority Mail International; Gift Card Flat Rate Envelope"],
|
24
|
+
["19", "Priority Mail International; Window Flat Rate Envelope"],
|
25
|
+
["20", "Priority Mail International; Small Flat Rate Envelope"],
|
26
|
+
["28", "Airmail M-Bag"]
|
27
|
+
].map do |code, name|
|
28
|
+
FriendlyShipping::ShippingMethod.new(
|
29
|
+
origin_countries: [Carmen::Country.coded('US')],
|
30
|
+
name: name,
|
31
|
+
service_code: code,
|
32
|
+
domestic: false,
|
33
|
+
international: true,
|
34
|
+
)
|
35
|
+
end.freeze
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/http_client'
|
4
|
+
require 'friendly_shipping/services/usps_international/shipping_methods'
|
5
|
+
require 'friendly_shipping/services/usps_international/serialize_rate_request'
|
6
|
+
require 'friendly_shipping/services/usps_international/parse_rate_response'
|
7
|
+
require 'friendly_shipping/services/usps_international/rate_estimate_options'
|
8
|
+
|
9
|
+
module FriendlyShipping
|
10
|
+
module Services
|
11
|
+
class UspsInternational
|
12
|
+
include Dry::Monads[:result]
|
13
|
+
|
14
|
+
attr_reader :test, :login, :client
|
15
|
+
|
16
|
+
CONTAINERS = {
|
17
|
+
rectanglular: 'RECTANGULAR',
|
18
|
+
roll: 'ROLL',
|
19
|
+
variable: 'VARIABLE'
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
MAIL_TYPES = {
|
23
|
+
all: 'ALL',
|
24
|
+
airmail: 'AIRMAIL MBAG',
|
25
|
+
envelope: 'ENVELOPE',
|
26
|
+
flat_rate: 'FLATRATE',
|
27
|
+
letter: 'LETTER',
|
28
|
+
large_envelope: 'LARGEENVELOPE',
|
29
|
+
package: 'PACKAGE',
|
30
|
+
post_cards: 'POSTCARDS'
|
31
|
+
}.freeze
|
32
|
+
|
33
|
+
TEST_URL = 'https://stg-secure.shippingapis.com/ShippingAPI.dll'
|
34
|
+
LIVE_URL = 'https://secure.shippingapis.com/ShippingAPI.dll'
|
35
|
+
|
36
|
+
RESOURCES = {
|
37
|
+
rates: 'IntlRateV2',
|
38
|
+
}.freeze
|
39
|
+
|
40
|
+
def initialize(login:, test: true, client: HttpClient.new)
|
41
|
+
@login = login
|
42
|
+
@test = test
|
43
|
+
@client = client
|
44
|
+
end
|
45
|
+
|
46
|
+
# Get rate estimates from USPS International
|
47
|
+
#
|
48
|
+
# @param [Physical::Shipment] shipment
|
49
|
+
# @param [FriendlyShipping::Services::UspsInternational::RateEstimateOptions] options What options
|
50
|
+
# to use for this rate estimate call
|
51
|
+
#
|
52
|
+
# @return [Result<Array<FriendlyShipping::Rate>>] When successfully parsing, an array of rates in a Success Monad.
|
53
|
+
# When the parsing is not successful or USPS can't give us rates, a Failure monad containing something that
|
54
|
+
# can be serialized into an error message using `to_s`.
|
55
|
+
def rate_estimates(shipment, options: RateEstimateOptions.new, debug: false)
|
56
|
+
rate_request_xml = SerializeRateRequest.call(shipment: shipment, login: login, options: options)
|
57
|
+
request = build_request(api: :rates, xml: rate_request_xml, debug: debug)
|
58
|
+
client.post(request).bind do |response|
|
59
|
+
ParseRateResponse.call(response: response, request: request, shipment: shipment, options: options)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def build_request(api:, xml:, debug:)
|
66
|
+
FriendlyShipping::Request.new(
|
67
|
+
url: base_url,
|
68
|
+
http_method: "POST",
|
69
|
+
body: "API=#{RESOURCES[api]}&XML=#{CGI.escape xml}",
|
70
|
+
readable_body: xml,
|
71
|
+
debug: debug
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
def base_url
|
76
|
+
test ? TEST_URL : LIVE_URL
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/lib/friendly_shipping.rb
CHANGED
@@ -20,6 +20,7 @@ require "friendly_shipping/services/ship_engine"
|
|
20
20
|
require "friendly_shipping/services/ups"
|
21
21
|
require "friendly_shipping/services/ups_freight"
|
22
22
|
require "friendly_shipping/services/usps"
|
23
|
+
require "friendly_shipping/services/usps_international"
|
23
24
|
|
24
25
|
module FriendlyShipping
|
25
26
|
end
|
metadata
CHANGED
@@ -1,14 +1,15 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: friendly_shipping
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Martin Meyerhoff
|
8
|
+
- Matthew Bass
|
8
9
|
autorequire:
|
9
10
|
bindir: exe
|
10
11
|
cert_chain: []
|
11
|
-
date: 2023-
|
12
|
+
date: 2023-04-18 00:00:00.000000000 Z
|
12
13
|
dependencies:
|
13
14
|
- !ruby/object:Gem::Dependency
|
14
15
|
name: dry-monads
|
@@ -286,6 +287,8 @@ files:
|
|
286
287
|
- lib/friendly_shipping/services/ship_engine.rb
|
287
288
|
- lib/friendly_shipping/services/ship_engine/bad_request.rb
|
288
289
|
- lib/friendly_shipping/services/ship_engine/bad_request_handler.rb
|
290
|
+
- lib/friendly_shipping/services/ship_engine/label_customs_options.rb
|
291
|
+
- lib/friendly_shipping/services/ship_engine/label_item_options.rb
|
289
292
|
- lib/friendly_shipping/services/ship_engine/label_options.rb
|
290
293
|
- lib/friendly_shipping/services/ship_engine/label_package_options.rb
|
291
294
|
- lib/friendly_shipping/services/ship_engine/parse_carrier_response.rb
|
@@ -372,6 +375,13 @@ files:
|
|
372
375
|
- lib/friendly_shipping/services/usps/serialize_time_in_transit_request.rb
|
373
376
|
- lib/friendly_shipping/services/usps/shipping_methods.rb
|
374
377
|
- lib/friendly_shipping/services/usps/timing_options.rb
|
378
|
+
- lib/friendly_shipping/services/usps_international.rb
|
379
|
+
- lib/friendly_shipping/services/usps_international/parse_package_rate.rb
|
380
|
+
- lib/friendly_shipping/services/usps_international/parse_rate_response.rb
|
381
|
+
- lib/friendly_shipping/services/usps_international/rate_estimate_options.rb
|
382
|
+
- lib/friendly_shipping/services/usps_international/rate_estimate_package_options.rb
|
383
|
+
- lib/friendly_shipping/services/usps_international/serialize_rate_request.rb
|
384
|
+
- lib/friendly_shipping/services/usps_international/shipping_methods.rb
|
375
385
|
- lib/friendly_shipping/shipment_options.rb
|
376
386
|
- lib/friendly_shipping/shipping_method.rb
|
377
387
|
- lib/friendly_shipping/timing.rb
|