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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c8c1f8749c1f3c1199a3ff41ec10d3a1d92f78dd
4
- data.tar.gz: f22645471497c2e0350659b4d1dd9cb35e799f22
3
+ metadata.gz: 6e4447aa9686205e27c1a070e4e8e3b3aafb26d8
4
+ data.tar.gz: acf1e59a8cc7372eb1bb7a942f7014184f2fd1e5
5
5
  SHA512:
6
- metadata.gz: fc5b536db417d7d087ae26a3121f61438b510b14b4fc8d94b13d3e25397f7e8f1e1b4c2cdaa18f13a4265c9aad1f6cdd1dcd6a3424bd6822d2ef8dc72a78cb7b
7
- data.tar.gz: f65dd002fe51c318074e6d4ae841f56d80637f7997d1becfe935e4d75f0ccc9da0d7df8746abbc38d6851068490b21d8182e67d26b912f3a5cc0995855f2fde9
6
+ metadata.gz: 513617be6accfb94f386f77c675f6e5520de5e1ec03b06b7c4bea70b72a47ab06f9ce74e5bad582757dba9029041c81cb28a7985a55b03b599973e3373319cc9
7
+ data.tar.gz: da1618a49058b3b5981dddb2a23b412fbd1fe1a28886bb823e777c2626e1e3fe749ca5a9b442da9e833708c46c6c47be2b09a10441d070de302a8f90f77eadb7
@@ -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.3.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 = /&lt;\S*&gt;/.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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FriendlyShipping
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.5"
5
5
  end
@@ -12,6 +12,7 @@ require "friendly_shipping/rate"
12
12
 
13
13
  require "friendly_shipping/services/ship_engine"
14
14
  require "friendly_shipping/services/ups"
15
+ require "friendly_shipping/services/usps"
15
16
 
16
17
  module FriendlyShipping
17
18
  end
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
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-04 00:00:00.000000000 Z
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.3.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.3.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