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 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