friendly_shipping 0.7.3 → 0.8.0
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/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
|