friendly_shipping 0.2.4 → 0.2.5
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/friendly_shipping.gemspec +1 -1
- data/lib/friendly_shipping/services/usps/choose_package_rate.rb +46 -0
- data/lib/friendly_shipping/services/usps/client.rb +36 -0
- data/lib/friendly_shipping/services/usps/machinable_package.rb +50 -0
- data/lib/friendly_shipping/services/usps/parse_package_rate.rb +135 -0
- data/lib/friendly_shipping/services/usps/parse_rate_response.rb +78 -0
- data/lib/friendly_shipping/services/usps/parse_xml_response.rb +40 -0
- data/lib/friendly_shipping/services/usps/serialize_rate_request.rb +89 -0
- data/lib/friendly_shipping/services/usps/shipping_methods.rb +52 -0
- data/lib/friendly_shipping/services/usps.rb +70 -0
- data/lib/friendly_shipping/version.rb +1 -1
- data/lib/friendly_shipping.rb +1 -0
- metadata +13 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6e4447aa9686205e27c1a070e4e8e3b3aafb26d8
|
4
|
+
data.tar.gz: acf1e59a8cc7372eb1bb7a942f7014184f2fd1e5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 513617be6accfb94f386f77c675f6e5520de5e1ec03b06b7c4bea70b72a47ab06f9ce74e5bad582757dba9029041c81cb28a7985a55b03b599973e3373319cc9
|
7
|
+
data.tar.gz: da1618a49058b3b5981dddb2a23b412fbd1fe1a28886bb823e777c2626e1e3fe749ca5a9b442da9e833708c46c6c47be2b09a10441d070de302a8f90f77eadb7
|
data/friendly_shipping.gemspec
CHANGED
@@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
|
|
26
26
|
spec.add_runtime_dependency "dry-monads", "~> 1.0"
|
27
27
|
spec.add_runtime_dependency "money", ">= 6.0.0"
|
28
28
|
spec.add_runtime_dependency "nokogiri", ">= 1.6"
|
29
|
-
spec.add_runtime_dependency "physical", "~> 0.
|
29
|
+
spec.add_runtime_dependency "physical", "~> 0.4"
|
30
30
|
spec.add_runtime_dependency "rest-client", "~> 2.0"
|
31
31
|
spec.required_ruby_version = '>= 2.4'
|
32
32
|
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class Usps
|
6
|
+
class ChoosePackageRate
|
7
|
+
class CannotDetermineRate < StandardError; end
|
8
|
+
# Some shipping rates use 'Flat Rate Boxes', indicating that
|
9
|
+
# they are available for ALL flat rate boxes.
|
10
|
+
FLAT_RATE_BOX = /Flat Rate Box/i.freeze
|
11
|
+
|
12
|
+
# Select the corresponding rate for a package from all the rates USPS returns to us
|
13
|
+
#
|
14
|
+
# @param [FriendlyShipping::ShippingMethod] shipping_method The shipping method we want to filter by
|
15
|
+
# @param [Physical::Package] package The package we want to match with a rate
|
16
|
+
# @param [Array<FriendlyShipping::Rate>] The rates we select from
|
17
|
+
#
|
18
|
+
# @return [FriendlyShipping::Rate] The rate that most closely matches our package
|
19
|
+
def self.call(shipping_method, package, rates)
|
20
|
+
# Keep all rates with the requested shipping method
|
21
|
+
rates_with_this_shipping_method = rates.select { |r| r.shipping_method == shipping_method }
|
22
|
+
|
23
|
+
# Keep only rates with the package type of this package
|
24
|
+
rates_with_this_package_type = rates_with_this_shipping_method.select do |r|
|
25
|
+
r.data[:box_name] == package.properties[:box_name] ||
|
26
|
+
r.data[:box_name] == :flat_rate_boxes && package.properties[:box_name]&.match?(FLAT_RATE_BOX)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Filter by our package's `hold_for_pickup` option
|
30
|
+
rates_with_this_hold_for_pickup_option = rates_with_this_package_type.select do |r|
|
31
|
+
r.data[:hold_for_pickup] == !!package.properties[:hold_for_pickup]
|
32
|
+
end
|
33
|
+
|
34
|
+
# At this point we should be left with a single rate. If we are not, raise an error,
|
35
|
+
# as that means we're missing some code.
|
36
|
+
if rates_with_this_hold_for_pickup_option.length > 1
|
37
|
+
raise CannotDetermineRate
|
38
|
+
end
|
39
|
+
|
40
|
+
# As we only have one rate left, return that without the array.
|
41
|
+
rates_with_this_hold_for_pickup_option.first
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'dry/monads/result'
|
4
|
+
require 'friendly_shipping/bad_request'
|
5
|
+
require 'rest-client'
|
6
|
+
|
7
|
+
module FriendlyShipping
|
8
|
+
module Services
|
9
|
+
class Usps
|
10
|
+
class Client
|
11
|
+
extend Dry::Monads::Result::Mixin
|
12
|
+
class <<self
|
13
|
+
# USPS allows both GET and POST request. We're using POST here as those request
|
14
|
+
# are less limited in size.
|
15
|
+
def post(request)
|
16
|
+
http_response = ::RestClient.post(request.url, request.body)
|
17
|
+
|
18
|
+
Success(convert_to_friendly_response(http_response))
|
19
|
+
rescue ::RestClient::Exception => e
|
20
|
+
Failure(e)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def convert_to_friendly_response(http_response)
|
26
|
+
FriendlyShipping::Response.new(
|
27
|
+
status: http_response.code,
|
28
|
+
body: http_response.body,
|
29
|
+
headers: http_response.headers
|
30
|
+
)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class Usps
|
6
|
+
# USPS has certain size and weight requirements for packages to
|
7
|
+
# be considered machinable. Machinable packages are generally
|
8
|
+
# less expensive to ship. For more information see:
|
9
|
+
# https://pe.usps.com/BusinessMail101?ViewName=Parcels
|
10
|
+
#
|
11
|
+
class MachinablePackage
|
12
|
+
attr_reader :package
|
13
|
+
|
14
|
+
MIN_LENGTH = Measured::Length(6, :inches)
|
15
|
+
MIN_WIDTH = Measured::Length(3, :inches)
|
16
|
+
MIN_HEIGHT = Measured::Length(0.25, :inches)
|
17
|
+
|
18
|
+
MAX_LENGTH = Measured::Length(27, :inches)
|
19
|
+
MAX_WIDTH = Measured::Length(17, :inches)
|
20
|
+
MAX_HEIGHT = Measured::Length(17, :inches)
|
21
|
+
|
22
|
+
MAX_WEIGHT = Measured::Weight(25, :pounds)
|
23
|
+
|
24
|
+
# @param [Physical::Package]
|
25
|
+
def initialize(package)
|
26
|
+
@package = package
|
27
|
+
end
|
28
|
+
|
29
|
+
def machinable?
|
30
|
+
at_least_minimum && at_most_maximum
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def at_least_minimum
|
36
|
+
package.length >= MIN_LENGTH &&
|
37
|
+
package.width >= MIN_WIDTH &&
|
38
|
+
package.height >= MIN_HEIGHT
|
39
|
+
end
|
40
|
+
|
41
|
+
def at_most_maximum
|
42
|
+
package.length <= MAX_LENGTH &&
|
43
|
+
package.width <= MAX_WIDTH &&
|
44
|
+
package.height <= MAX_HEIGHT &&
|
45
|
+
package.weight <= MAX_WEIGHT
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class Usps
|
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
|
+
BOX_REGEX = {
|
23
|
+
flat_rate_boxes: 'Flat Rate Boxes',
|
24
|
+
large_flat_rate_box: 'Large Flat Rate Box',
|
25
|
+
medium_flat_rate_box: 'Medium Flat Rate Box',
|
26
|
+
small_flat_rate_box: 'Small Flat Rate Box',
|
27
|
+
regional_rate_box_a: 'Regional Rate Box A',
|
28
|
+
regional_rate_box_b: 'Regional Rate Box B',
|
29
|
+
regional_rate_box_c: 'Regional Rate Box C',
|
30
|
+
flat_rate_envelope: 'Flat Rate Envelope',
|
31
|
+
legal_flat_rate_envelope: 'Legal Flat Rate Envelope',
|
32
|
+
padded_flat_rate_envelope: 'Padded Flat Rate Envelope',
|
33
|
+
gift_card_flat_rate_envelope: 'Gift Card Flat Rate Envelope',
|
34
|
+
window_flat_rate_envelope: 'Window Flat Rate Envelope',
|
35
|
+
small_flat_rate_envelope: 'Small Flat Rate Envelope',
|
36
|
+
large_envelope: 'Large Envelope',
|
37
|
+
parcel: 'Parcel',
|
38
|
+
postcards: 'Postcards'
|
39
|
+
}.map { |k, v| "(?<#{k}>#{v})" }.join("|").freeze
|
40
|
+
|
41
|
+
# We use this for identifying rates that use the Hold for Pickup service.
|
42
|
+
HOLD_FOR_PICKUP = /Hold for Pickup/i.freeze
|
43
|
+
|
44
|
+
# For most rate options, USPS will return how many business days it takes to deliver this
|
45
|
+
# package in the format "{1,2,3}-Day". We can filter this out using the below Regex.
|
46
|
+
DAYS_TO_DELIVERY = /(?<days>\d)-Day/.freeze
|
47
|
+
|
48
|
+
# When delivering to military ZIP codes, we don't actually get a timing estimate, but instead the string
|
49
|
+
# "Military". We use this to indicate that this rate is for a military zip code in the rates' data Hash.
|
50
|
+
MILITARY = /MILITARY/i.freeze
|
51
|
+
|
52
|
+
# The tags used in the rate node that we get information from.
|
53
|
+
SERVICE_CODE_TAG = 'CLASSID'
|
54
|
+
SERVICE_NAME_TAG = 'MailService'
|
55
|
+
RATE_TAG = 'Rate'
|
56
|
+
COMMERCIAL_RATE_TAG = 'CommercialRate'
|
57
|
+
CURRENCY = Money::Currency.new('USD').freeze
|
58
|
+
|
59
|
+
class << self
|
60
|
+
def call(rate_node, package)
|
61
|
+
# "A mail class identifier for the postage returned. Not necessarily unique within a <Package/>."
|
62
|
+
# (from the USPS docs). We save this on the data Hash, but do not use it for identifying shipping methods.
|
63
|
+
service_code = rate_node.attributes[SERVICE_CODE_TAG].value
|
64
|
+
|
65
|
+
# The long string discussed above.
|
66
|
+
service_name = rate_node.at(SERVICE_NAME_TAG).text
|
67
|
+
|
68
|
+
# Does this rate assume Hold for Pickup service?
|
69
|
+
hold_for_pickup = service_name.match?(HOLD_FOR_PICKUP)
|
70
|
+
|
71
|
+
# Is the destination a military ZIP code?
|
72
|
+
military = service_name.match?(MILITARY)
|
73
|
+
|
74
|
+
# If we get a days-to-delivery indication, save it in the `days_to_delivery` variable.
|
75
|
+
days_to_delivery_match = service_name.match(DAYS_TO_DELIVERY)
|
76
|
+
days_to_delivery = if days_to_delivery_match
|
77
|
+
days_to_delivery_match.named_captures.values.first.to_i
|
78
|
+
end
|
79
|
+
|
80
|
+
# Clean up the long string
|
81
|
+
service_name.gsub!(SERVICE_NAME_SUBSTITUTIONS, '')
|
82
|
+
|
83
|
+
# Some USPS services only offer commercial pricing. Unfortunately, USPS then returns a retail rate of 0.
|
84
|
+
# In these cases, return the commercial rate instead of the normal rate.
|
85
|
+
# Some rates are available in both commercial and retail pricing - if we want the commercial pricing here,
|
86
|
+
# we need to specify the commercial_pricing property on the `Physical::Package`.
|
87
|
+
rate_value = if (package.properties[:commercial_pricing] || rate_node.at(RATE_TAG).text.to_d.zero?) && rate_node.at(COMMERCIAL_RATE_TAG)
|
88
|
+
rate_node.at(COMMERCIAL_RATE_TAG).text.to_d
|
89
|
+
else
|
90
|
+
rate_node.at(RATE_TAG).text.to_d
|
91
|
+
end
|
92
|
+
|
93
|
+
# The rate expressed as a RubyMoney objext
|
94
|
+
rate = Money.new(rate_value * CURRENCY.subunit_to_unit, CURRENCY)
|
95
|
+
|
96
|
+
# Which shipping method does this rate belong to? This is trickier than it sounds, because we match
|
97
|
+
# strings here, and we have a `Priority Mail` and `Priority Mail Express` shipping method.
|
98
|
+
# If we have multiple matches, we take the longest matching shipping method name so `Express` rates
|
99
|
+
# do not accidentally get marked as `Priority` only.
|
100
|
+
possible_shipping_methods = SHIPPING_METHODS.select do |sm|
|
101
|
+
service_name.tr('-', ' ').upcase.starts_with?(sm.service_code)
|
102
|
+
end.sort_by do |shipping_method|
|
103
|
+
shipping_method.name.length
|
104
|
+
end
|
105
|
+
shipping_method = possible_shipping_methods.last
|
106
|
+
|
107
|
+
# We find out the box name using a bit of Regex magic using named captures. See the `BOX_REGEX`
|
108
|
+
# constant above.
|
109
|
+
box_name_match = service_name.match(/#{BOX_REGEX}/)
|
110
|
+
box_name = if box_name_match
|
111
|
+
box_name_match.named_captures.compact.keys.last.to_sym
|
112
|
+
end
|
113
|
+
|
114
|
+
# Combine all the gathered information in a FriendlyShipping::Rate object.
|
115
|
+
# Careful: This rate is only for one package within the shipment, and we get multiple
|
116
|
+
# rates per package for the different shipping method/box/hold for pickup combinations.
|
117
|
+
FriendlyShipping::Rate.new(
|
118
|
+
shipping_method: shipping_method,
|
119
|
+
amounts: { package.id => rate },
|
120
|
+
data: {
|
121
|
+
package: package,
|
122
|
+
box_name: box_name,
|
123
|
+
hold_for_pickup: hold_for_pickup,
|
124
|
+
days_to_delivery: days_to_delivery,
|
125
|
+
military: military,
|
126
|
+
full_mail_service: service_name,
|
127
|
+
service_code: service_code
|
128
|
+
}
|
129
|
+
)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/services/usps/parse_xml_response'
|
4
|
+
require 'friendly_shipping/services/usps/parse_package_rate'
|
5
|
+
require 'friendly_shipping/services/usps/choose_package_rate'
|
6
|
+
|
7
|
+
module FriendlyShipping
|
8
|
+
module Services
|
9
|
+
class Usps
|
10
|
+
class ParseRateResponse
|
11
|
+
class BoxNotFoundError < StandardError; end
|
12
|
+
|
13
|
+
class << self
|
14
|
+
# Parse a response from USPS' rating API
|
15
|
+
#
|
16
|
+
# @param [FriendlyShipping::Request] request The request that was used to obtain this Response
|
17
|
+
# @param [FriendlyShipping::Response] response The response that USPS returned
|
18
|
+
# @param [Physical::Shipment] shipment The shipment object we're trying to get results for
|
19
|
+
# @return [Result<Array<FriendlyShipping::Rate>>] When successfully parsing, an array of rates in a Success Monad.
|
20
|
+
def call(request:, response:, shipment:)
|
21
|
+
# Filter out error responses and directly return a failure
|
22
|
+
parsing_result = ParseXMLResponse.call(response.body, 'RateV4Response')
|
23
|
+
parsing_result.fmap do |xml|
|
24
|
+
# Get all the possible rates for each package
|
25
|
+
rates_by_package = rates_from_response_node(xml, shipment)
|
26
|
+
|
27
|
+
SHIPPING_METHODS.map do |shipping_method|
|
28
|
+
# For every package ...
|
29
|
+
matching_rates = rates_by_package.map do |package, rates|
|
30
|
+
# ... choose the rate that fits this package best.
|
31
|
+
ChoosePackageRate.call(shipping_method, package, rates)
|
32
|
+
end.compact # Some shipping rates are not available for every shipping method.
|
33
|
+
|
34
|
+
# in this case, go to the next shipping method.
|
35
|
+
next if matching_rates.empty?
|
36
|
+
|
37
|
+
# return one rate for all packages with the amount keys being the package IDs.
|
38
|
+
FriendlyShipping::Rate.new(
|
39
|
+
amounts: matching_rates.map(&:amounts).reduce({}, :merge),
|
40
|
+
shipping_method: shipping_method,
|
41
|
+
data: matching_rates.first.data,
|
42
|
+
original_request: request,
|
43
|
+
original_response: response
|
44
|
+
)
|
45
|
+
end.compact
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
PACKAGE_NODE_XPATH = '//Package'
|
52
|
+
SERVICE_NODE_NAME = 'Postage'
|
53
|
+
|
54
|
+
# Iterate over all packages and parse the rates for each package
|
55
|
+
#
|
56
|
+
# @param [Nokogiri::XML::Node] xml The XML document containing packages and rates
|
57
|
+
# @param [Physical::Shipment] shipment The shipment we're trying to get rates for
|
58
|
+
#
|
59
|
+
# @return [Hash<Physical::Package => Array<FriendlyShipping::Rate>>]
|
60
|
+
def rates_from_response_node(xml, shipment)
|
61
|
+
xml.xpath(PACKAGE_NODE_XPATH).each_with_object({}) do |package_node, result|
|
62
|
+
package_id = package_node['ID']
|
63
|
+
corresponding_package = shipment.packages.detect { |p| p.id == package_id }
|
64
|
+
|
65
|
+
# There should always be a package in the original shipment that corresponds to the package ID
|
66
|
+
# in the USPS response.
|
67
|
+
raise BoxNotFoundError if corresponding_package.nil?
|
68
|
+
|
69
|
+
result[corresponding_package] = package_node.xpath(SERVICE_NODE_NAME).map do |service_node|
|
70
|
+
ParsePackageRate.call(service_node, corresponding_package)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class Usps
|
6
|
+
class ParseXMLResponse
|
7
|
+
extend Dry::Monads::Result::Mixin
|
8
|
+
ERROR_ROOT_TAG = 'Error'
|
9
|
+
|
10
|
+
class << self
|
11
|
+
def call(response_body, expected_root_tag)
|
12
|
+
xml = Nokogiri.XML(response_body)
|
13
|
+
|
14
|
+
if xml.root.nil? || ![expected_root_tag, ERROR_ROOT_TAG].include?(xml.root.name)
|
15
|
+
Failure('Invalid document')
|
16
|
+
elsif request_successful?(xml)
|
17
|
+
Success(xml)
|
18
|
+
else
|
19
|
+
Failure(error_message(xml))
|
20
|
+
end
|
21
|
+
rescue Nokogiri::XML::SyntaxError => e
|
22
|
+
Failure(e)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def request_successful?(xml)
|
28
|
+
xml.xpath('Error/Number')&.text.blank?
|
29
|
+
end
|
30
|
+
|
31
|
+
def error_message(xml)
|
32
|
+
number = xml.xpath('Error/Number')&.text
|
33
|
+
desc = xml.xpath('Error/Description')&.text
|
34
|
+
[number, desc].select(&:present?).join(': ').presence || 'USPS could not process the request.'
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/services/usps/machinable_package'
|
4
|
+
|
5
|
+
module FriendlyShipping
|
6
|
+
module Services
|
7
|
+
class Usps
|
8
|
+
class SerializeRateRequest
|
9
|
+
MAX_REGULAR_PACKAGE_SIDE = Measured::Length(12, :inches)
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# @param [Physical::Shipment] shipment The shipment we want to get rates for
|
13
|
+
# shipment.packages[0].properties[:box_code] Can be :rectangular, :variable,
|
14
|
+
# or a flat rate container defined in CONTAINERS.
|
15
|
+
# @param [String] login The USPS login code
|
16
|
+
# @param [FriendlyShipping::ShippingMethod] service The shipping methods we want to get rates
|
17
|
+
# for. If empty, we get all of them
|
18
|
+
# @return Array<[FriendlyShipping::Rate]> A set of Rates that this package may be sent with
|
19
|
+
def call(shipment:, login:, shipping_method: nil)
|
20
|
+
xml_builder = Nokogiri::XML::Builder.new do |xml|
|
21
|
+
xml.RateV4Request('USERID' => login) do
|
22
|
+
shipment.packages.each do |package|
|
23
|
+
xml.Package('ID' => package.id) do
|
24
|
+
xml.Service(service_code_by(shipping_method, package))
|
25
|
+
if package.properties[:first_class_mail_type]
|
26
|
+
xml.FirstClassMailType(FIRST_CLASS_MAIL_TYPES[package.properties[:first_class_mail_type]])
|
27
|
+
end
|
28
|
+
xml.ZipOrigination(shipment.origin.zip)
|
29
|
+
xml.ZipDestination(shipment.destination.zip)
|
30
|
+
xml.Pounds(0)
|
31
|
+
xml.Ounces(ounces_for(package))
|
32
|
+
size_code = size_code_for(package)
|
33
|
+
container = CONTAINERS[package.properties[:box_name] || :rectangular]
|
34
|
+
xml.Container(container)
|
35
|
+
xml.Size(size_code)
|
36
|
+
xml.Width("%0.2f" % package.width.convert_to(:inches).value.to_f)
|
37
|
+
xml.Length("%0.2f" % package.length.convert_to(:inches).value.to_f)
|
38
|
+
xml.Height("%0.2f" % package.height.convert_to(:inches).value.to_f)
|
39
|
+
xml.Girth("%0.2f" % girth(package))
|
40
|
+
xml.Machinable(machinable(package))
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
xml_builder.to_xml
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def machinable(package)
|
51
|
+
MachinablePackage.new(package).machinable? ? 'TRUE' : 'FALSE'
|
52
|
+
end
|
53
|
+
|
54
|
+
def size_code_for(package)
|
55
|
+
if package.dimensions.max <= MAX_REGULAR_PACKAGE_SIDE
|
56
|
+
'REGULAR'
|
57
|
+
else
|
58
|
+
'LARGE'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def service_code_by(shipping_method, package)
|
63
|
+
return 'ALL' unless shipping_method
|
64
|
+
|
65
|
+
if package.properties[:commercial_pricing]
|
66
|
+
"#{shipping_method.service_code} COMMERCIAL"
|
67
|
+
else
|
68
|
+
shipping_method.service_code
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def ounces_for(package)
|
73
|
+
ounces = package.weight.convert_to(:ounces).value.to_f.round(2).ceil
|
74
|
+
ounces == 16 ? 15.999 : [ounces, 1].max
|
75
|
+
end
|
76
|
+
|
77
|
+
def strip_zip(zip)
|
78
|
+
zip.to_s.scan(/\d{5}/).first || zip
|
79
|
+
end
|
80
|
+
|
81
|
+
def girth(package)
|
82
|
+
width, length = package.dimensions.sort.first(2)
|
83
|
+
(width.scale(2) + length.scale(2)).convert_to(:inches).value.to_f
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FriendlyShipping
|
4
|
+
module Services
|
5
|
+
class Usps
|
6
|
+
CONTAINERS = {
|
7
|
+
variable: 'VARIABLE',
|
8
|
+
rectangular: 'RECTANGULAR',
|
9
|
+
large_flat_rate_box: 'LG FLAT RATE BOX',
|
10
|
+
medium_flat_rate_box: 'MD FLAT RATE BOX',
|
11
|
+
small_flat_rate_box: 'SM FLAT RATE BOX',
|
12
|
+
regional_rate_box_a: 'REGIONALRATEBOXA',
|
13
|
+
regional_rate_box_b: 'REGIONALRATEBOXB',
|
14
|
+
flat_rate_envelope: 'FLAT RATE ENVELOPE',
|
15
|
+
legal_flat_rate_envelope: 'LEGAL FLAT RATE ENVELOPE',
|
16
|
+
padded_flat_rate_envelope: 'PADDED FLAT RATE ENVELOPE',
|
17
|
+
gift_card_flat_rate_envelope: 'GIFT CARD FLAT RATE ENVELOPE',
|
18
|
+
window_flat_rate_envelope: 'WINDOW FLAT RATE ENVELOPE',
|
19
|
+
small_flat_rate_envelope: 'SM FLAT RATE ENVELOPE',
|
20
|
+
cubic_soft_pack: 'CUBIC SOFT PACK',
|
21
|
+
cubic_parcels: 'CUBIC_PARCELS'
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
FIRST_CLASS_MAIL_TYPES = {
|
25
|
+
letter: 'LETTER',
|
26
|
+
flat: 'FLAT',
|
27
|
+
parcel: 'PARCEL',
|
28
|
+
post_card: 'POSTCARD',
|
29
|
+
package_service: 'PACKAGESERVICE'
|
30
|
+
}.freeze
|
31
|
+
|
32
|
+
SHIPPING_METHODS = [
|
33
|
+
'First-Class',
|
34
|
+
'First-Class Package Service',
|
35
|
+
'Priority',
|
36
|
+
'Priority Mail Express',
|
37
|
+
'Standard Post',
|
38
|
+
'Retail Ground',
|
39
|
+
'Media Mail',
|
40
|
+
'Library Mail',
|
41
|
+
].map do |shipping_method_name|
|
42
|
+
FriendlyShipping::ShippingMethod.new(
|
43
|
+
origin_countries: [Carmen::Country.coded('US')],
|
44
|
+
name: shipping_method_name,
|
45
|
+
service_code: shipping_method_name.tr('-', ' ').upcase,
|
46
|
+
domestic: true,
|
47
|
+
international: false
|
48
|
+
)
|
49
|
+
end.freeze
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'friendly_shipping/services/usps/client'
|
4
|
+
require 'friendly_shipping/services/usps/shipping_methods'
|
5
|
+
require 'friendly_shipping/services/usps/serialize_rate_request'
|
6
|
+
require 'friendly_shipping/services/usps/parse_rate_response'
|
7
|
+
|
8
|
+
module FriendlyShipping
|
9
|
+
module Services
|
10
|
+
class Usps
|
11
|
+
include Dry::Monads::Result::Mixin
|
12
|
+
|
13
|
+
attr_reader :test, :login, :client
|
14
|
+
|
15
|
+
CARRIER = FriendlyShipping::Carrier.new(
|
16
|
+
id: 'usps',
|
17
|
+
name: 'United States Postal Service',
|
18
|
+
code: 'usps',
|
19
|
+
shipping_methods: SHIPPING_METHODS
|
20
|
+
)
|
21
|
+
|
22
|
+
TEST_URL = 'https://stg-secure.shippingapis.com/ShippingAPI.dll'
|
23
|
+
LIVE_URL = 'https://secure.shippingapis.com/ShippingAPI.dll'
|
24
|
+
|
25
|
+
RESOURCES = {
|
26
|
+
rates: 'RateV4',
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
def initialize(login:, test: true, client: Client)
|
30
|
+
@login = login
|
31
|
+
@test = test
|
32
|
+
@client = client
|
33
|
+
end
|
34
|
+
|
35
|
+
def carriers
|
36
|
+
Success([CARRIER])
|
37
|
+
end
|
38
|
+
|
39
|
+
# Get rate estimates from USPS
|
40
|
+
#
|
41
|
+
# @param [Physical::Shipment] shipment The shipment object we're trying to get results for
|
42
|
+
# USPS returns rates on a package-by-package basis, so the options for obtaining rates are
|
43
|
+
# set on the [Physical::Package.container.properties] hash. The possible options are:
|
44
|
+
# @property [Symbol] box_name The type of box we want to get rates for. Has to be one of the keys
|
45
|
+
# of FriendlyShipping::Services::Usps::CONTAINERS.
|
46
|
+
# @property [Boolean] commercial_pricing Whether we prefer commercial pricing results or retail results
|
47
|
+
# @property [Boolean] hold_for_pickup Whether we want a rate with Hold For Pickup Service
|
48
|
+
# @param [Physical::ShippingMethod] shipping_method The shipping method ("service" in USPS parlance) we want
|
49
|
+
# to get rates for.
|
50
|
+
#
|
51
|
+
# @return [Result<Array<FriendlyShipping::Rate>>] When successfully parsing, an array of rates in a Success Monad.
|
52
|
+
# When the parsing is not successful or USPS can't give us rates, a Failure monad containing something that
|
53
|
+
# can be serialized into an error message using `to_s`.
|
54
|
+
def rate_estimates(shipment, _carriers, shipping_method: nil)
|
55
|
+
rate_request_xml = SerializeRateRequest.call(shipment: shipment, login: login, shipping_method: shipping_method)
|
56
|
+
request = FriendlyShipping::Request.new(url: base_url, body: "API=#{RESOURCES[:rates]}&XML=#{CGI.escape rate_request_xml}")
|
57
|
+
|
58
|
+
client.post(request).bind do |response|
|
59
|
+
ParseRateResponse.call(response: response, request: request, shipment: shipment)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def base_url
|
66
|
+
test ? TEST_URL : LIVE_URL
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/friendly_shipping.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: friendly_shipping
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Martin Meyerhoff
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-07-
|
11
|
+
date: 2019-07-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: data_uri
|
@@ -72,14 +72,14 @@ dependencies:
|
|
72
72
|
requirements:
|
73
73
|
- - "~>"
|
74
74
|
- !ruby/object:Gem::Version
|
75
|
-
version: 0.
|
75
|
+
version: '0.4'
|
76
76
|
type: :runtime
|
77
77
|
prerelease: false
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
79
79
|
requirements:
|
80
80
|
- - "~>"
|
81
81
|
- !ruby/object:Gem::Version
|
82
|
-
version: 0.
|
82
|
+
version: '0.4'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
84
|
name: rest-client
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -181,6 +181,15 @@ files:
|
|
181
181
|
- lib/friendly_shipping/services/ups/serialize_package_node.rb
|
182
182
|
- lib/friendly_shipping/services/ups/serialize_rating_service_selection_request.rb
|
183
183
|
- lib/friendly_shipping/services/ups/shipping_methods.rb
|
184
|
+
- lib/friendly_shipping/services/usps.rb
|
185
|
+
- lib/friendly_shipping/services/usps/choose_package_rate.rb
|
186
|
+
- lib/friendly_shipping/services/usps/client.rb
|
187
|
+
- lib/friendly_shipping/services/usps/machinable_package.rb
|
188
|
+
- lib/friendly_shipping/services/usps/parse_package_rate.rb
|
189
|
+
- lib/friendly_shipping/services/usps/parse_rate_response.rb
|
190
|
+
- lib/friendly_shipping/services/usps/parse_xml_response.rb
|
191
|
+
- lib/friendly_shipping/services/usps/serialize_rate_request.rb
|
192
|
+
- lib/friendly_shipping/services/usps/shipping_methods.rb
|
184
193
|
- lib/friendly_shipping/shipping_method.rb
|
185
194
|
- lib/friendly_shipping/version.rb
|
186
195
|
homepage: https://github.com/friendly_cart/friendly_shipping
|