friendly_shipping 0.2.4 → 0.2.5
Sign up to get free protection for your applications and to get access to all the features.
- 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
|