friendly_shipping 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/friendly_shipping.gemspec +1 -1
  4. data/lib/friendly_shipping/package_options.rb +4 -0
  5. data/lib/friendly_shipping/rate.rb +1 -1
  6. data/lib/friendly_shipping/services/ship_engine/label_customs_options.rb +25 -0
  7. data/lib/friendly_shipping/services/ship_engine/label_item_options.rb +27 -0
  8. data/lib/friendly_shipping/services/ship_engine/label_options.rb +5 -1
  9. data/lib/friendly_shipping/services/ship_engine/label_package_options.rb +2 -1
  10. data/lib/friendly_shipping/services/ship_engine/serialize_label_shipment.rb +37 -0
  11. data/lib/friendly_shipping/services/ups/label_item_options.rb +4 -1
  12. data/lib/friendly_shipping/services/ups/parse_rate_response.rb +3 -3
  13. data/lib/friendly_shipping/services/ups/parse_shipment_accept_response.rb +1 -1
  14. data/lib/friendly_shipping/services/ups/serialize_address_snippet.rb +5 -2
  15. data/lib/friendly_shipping/services/ups/serialize_shipment_confirm_request.rb +6 -5
  16. data/lib/friendly_shipping/services/ups_freight/api_error.rb +2 -0
  17. data/lib/friendly_shipping/services/usps/rate_estimate_package_options.rb +4 -8
  18. data/lib/friendly_shipping/services/usps_international/parse_package_rate.rb +80 -0
  19. data/lib/friendly_shipping/services/usps_international/parse_rate_response.rb +86 -0
  20. data/lib/friendly_shipping/services/usps_international/rate_estimate_options.rb +28 -0
  21. data/lib/friendly_shipping/services/usps_international/rate_estimate_package_options.rb +45 -0
  22. data/lib/friendly_shipping/services/usps_international/serialize_rate_request.rb +75 -0
  23. data/lib/friendly_shipping/services/usps_international/shipping_methods.rb +38 -0
  24. data/lib/friendly_shipping/services/usps_international.rb +80 -0
  25. data/lib/friendly_shipping/version.rb +1 -1
  26. data/lib/friendly_shipping.rb +1 -0
  27. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c32d3c28aea8a8cc0367288ea92fd4c1f29c5e18eb680a82f273e3f4319d14e
4
- data.tar.gz: 6618acc12ea1a92156dbfd2864a812d4c44cebd2b4f4c05b13d3d19d20d6fc62
3
+ metadata.gz: 269d5f03467b9fc61a71f0585c7ff434156465e008d01e26e319666a15b13fb1
4
+ data.tar.gz: 006fe64631e6b7eef89c796d5474dafb71896e7eff8d081572c6a85a641fb537
5
5
  SHA512:
6
- metadata.gz: ef98ea7a648d25aba9d2d2df3d4d624a5c6413f1008d6bd9e2aff3520779aee02c24163242152698cdacf91a9c9b75a1686f950a53b1eb1aa406f6882c2864fe
7
- data.tar.gz: 6e6d8669b5fa64c18cbf89a215e642042f1bcc31f35900a9836782bd80ca307e6bdeafdf36b8a946e0d3da3068338b1db69133827af0ec59cd2f538cb1c74492
6
+ metadata.gz: b0ca75ff4a78b69b1da507dbc5fc9872629217f5a28fd37ea9645078bbda1b592beb74eb71b44b69c8e4649b878e1e715060ad545f8b8b8bdd19a3b256996112
7
+ data.tar.gz: 1945415f57c1ab8d22bf80e582b22938235965c1db3981c78ef3c6c76087507ae4c5a9e3db7a2a1e27899a9e90ef2a3d97b6cee24673b51698df0c07747aae49
data/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.8.0] - 2023-04-18
8
+ - Rails 7 support: Fix deprecation warning about ActiveSupport#sum (#164)
9
+ - UPS Service: Truncate product descriptions (#163)
10
+ - TForce Service: Handle Timeouts gracefully (#162)
11
+ - UPS Service: Support per-item origin countries for paperless invoices (#161)
12
+ - USPS Service: Fix for currency formatting when shipping internationally (#160)
13
+ - ShipEngine Service: Add support for customs information (#159)
14
+ - UPS Service: Require both name and attention name for international shipping (#158)
15
+ - UPS Service: Allow third-party billing for taxes and fees (#156)
16
+ - USPS: New service for international shipping (#155)
17
+ - UPS Service: Parse missing package charges (#154)
18
+
7
19
  ## [0.7.3] - 2023-01-24
8
20
  - UPS Service: Record USPS tracking code (#153)
9
21
 
@@ -7,7 +7,7 @@ require "friendly_shipping/version"
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = "friendly_shipping"
9
9
  spec.version = FriendlyShipping::VERSION
10
- spec.authors = ["Martin Meyerhoff"]
10
+ spec.authors = ["Martin Meyerhoff", "Matthew Bass"]
11
11
  spec.email = ["mamhoff@gmail.com"]
12
12
 
13
13
  spec.summary = "An integration layer for shipping services"
@@ -24,5 +24,9 @@ module FriendlyShipping
24
24
 
25
25
  attr_reader :item_options,
26
26
  :item_options_class
27
+
28
+ def value_or_default(key, default, kwargs)
29
+ kwargs.key?(key) ? kwargs.delete(key) : default
30
+ end
27
31
  end
28
32
  end
@@ -39,7 +39,7 @@ module FriendlyShipping
39
39
  def total_amount
40
40
  raise NoAmountsGiven if amounts.empty?
41
41
 
42
- amounts.map { |_name, amount| amount }.sum
42
+ amounts.map { |_name, amount| amount }.sum(Money.new(0, 'USD'))
43
43
  end
44
44
  end
45
45
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class ShipEngine
6
+ # Represents customs options for obtaining international shipment labels.
7
+ # @option contents [String] The contents of the shipment.
8
+ # Valid values are: gift, merchandise, returned_goods, documents, sample
9
+ # @option non_delivery [String] Indicates what should be done if the shipment cannot be delivered.
10
+ # Valid values are: treat_as_abandoned, return_to_sender
11
+ class LabelCustomsOptions
12
+ attr_reader :contents,
13
+ :non_delivery
14
+
15
+ def initialize(
16
+ contents: "merchandise",
17
+ non_delivery: "return_to_sender"
18
+ )
19
+ @contents = contents
20
+ @non_delivery = non_delivery
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/item_options'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ class ShipEngine
8
+ # Represents item options for obtaining shipment labels.
9
+ # @option commodity_code [String] This item's HS or NMFC code for international shipments.
10
+ # @option country_of_origin [String] This item's country of origin for international shipments.
11
+ class LabelItemOptions < FriendlyShipping::ItemOptions
12
+ attr_reader :commodity_code,
13
+ :country_of_origin
14
+
15
+ def initialize(
16
+ commodity_code: nil,
17
+ country_of_origin: nil,
18
+ **kwargs
19
+ )
20
+ @commodity_code = commodity_code
21
+ @country_of_origin = country_of_origin
22
+ super(**kwargs)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'friendly_shipping/shipment_options'
4
+ require 'friendly_shipping/services/ship_engine/label_customs_options'
4
5
 
5
6
  module FriendlyShipping
6
7
  module Services
@@ -17,19 +18,22 @@ module FriendlyShipping
17
18
  attr_reader :shipping_method,
18
19
  :label_download_type,
19
20
  :label_format,
20
- :label_image_id
21
+ :label_image_id,
22
+ :customs_options
21
23
 
22
24
  def initialize(
23
25
  shipping_method:,
24
26
  label_download_type: :url,
25
27
  label_format: :pdf,
26
28
  label_image_id: nil,
29
+ customs_options: LabelCustomsOptions.new,
27
30
  **kwargs
28
31
  )
29
32
  @shipping_method = shipping_method
30
33
  @label_download_type = label_download_type
31
34
  @label_format = label_format
32
35
  @label_image_id = label_image_id
36
+ @customs_options = customs_options
33
37
  super(**kwargs.merge(package_options_class: LabelPackageOptions))
34
38
  end
35
39
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'friendly_shipping/package_options'
4
+ require 'friendly_shipping/services/ship_engine/label_item_options'
4
5
 
5
6
  module FriendlyShipping
6
7
  module Services
@@ -20,7 +21,7 @@ module FriendlyShipping
20
21
  def initialize(package_code: nil, messages: [], **kwargs)
21
22
  @package_code = package_code
22
23
  @messages = messages
23
- super(**kwargs)
24
+ super(**kwargs.merge(item_options_class: LabelItemOptions))
24
25
  end
25
26
  end
26
27
  end
@@ -21,6 +21,10 @@ module FriendlyShipping
21
21
  shipment_hash[:shipment][:carrier_id] = options.shipping_method.carrier.id
22
22
  end
23
23
 
24
+ if international?(shipment)
25
+ shipment_hash[:shipment][:customs] = serialize_customs(shipment.packages, options)
26
+ end
27
+
24
28
  if test
25
29
  shipment_hash[:test_label] = true
26
30
  end
@@ -68,6 +72,35 @@ module FriendlyShipping
68
72
  end
69
73
  end
70
74
 
75
+ def serialize_customs(packages, options)
76
+ {
77
+ contents: options.customs_options.contents,
78
+ non_delivery: options.customs_options.non_delivery,
79
+ customs_items: serialize_customs_items(packages, options)
80
+ }
81
+ end
82
+
83
+ def serialize_customs_items(packages, options)
84
+ packages.map do |package|
85
+ package.items.group_by(&:sku).map do |sku, items|
86
+ reference_item = items.first
87
+ package_options = options.options_for_package(package)
88
+ item_options = package_options.options_for_item(reference_item)
89
+ {
90
+ sku: sku,
91
+ description: reference_item.description,
92
+ quantity: items.count,
93
+ value: {
94
+ amount: reference_item.cost.to_d,
95
+ currency: reference_item.cost.currency.to_s
96
+ },
97
+ harmonized_tariff_code: item_options.commodity_code,
98
+ country_of_origin: item_options.country_of_origin
99
+ }
100
+ end
101
+ end.flatten
102
+ end
103
+
71
104
  def serialize_weight(weight)
72
105
  ounces = weight.convert_to(:ounce).value.to_f
73
106
  {
@@ -78,6 +111,10 @@ module FriendlyShipping
78
111
  }
79
112
  }
80
113
  end
114
+
115
+ def international?(shipment)
116
+ shipment.origin.country != shipment.destination.country
117
+ end
81
118
  end
82
119
  end
83
120
  end
@@ -49,14 +49,17 @@ module FriendlyShipping
49
49
  other: 'OTH'
50
50
  }.freeze
51
51
 
52
- attr_reader :commodity_code
52
+ attr_reader :commodity_code,
53
+ :country_of_origin
53
54
 
54
55
  def initialize(
55
56
  commodity_code: nil,
57
+ country_of_origin: nil,
56
58
  product_unit_of_measure: :number,
57
59
  **kwargs
58
60
  )
59
61
  @commodity_code = commodity_code
62
+ @country_of_origin = country_of_origin
60
63
  @product_unit_of_measure = product_unit_of_measure
61
64
  super(**kwargs)
62
65
  end
@@ -69,11 +69,11 @@ module FriendlyShipping
69
69
  def build_packages(rated_shipment)
70
70
  rated_shipment.css('RatedPackage').map do |rated_package|
71
71
  {
72
- transportation_charges: ParseMoneyElement.call(rated_package.at('TransportationCharges')).last,
73
- base_service_charge: ParseMoneyElement.call(rated_package.at('BaseServiceCharge')).last,
72
+ transportation_charges: ParseMoneyElement.call(rated_package.at('TransportationCharges'))&.last,
73
+ base_service_charge: ParseMoneyElement.call(rated_package.at('BaseServiceCharge'))&.last,
74
74
  service_options_charges: ParseMoneyElement.call(rated_package.at('ServiceOptionsCharges'))&.last,
75
75
  itemized_charges: extract_charges(rated_package.xpath('ItemizedCharges')),
76
- total_charges: ParseMoneyElement.call(rated_package.at('TotalCharges')).last,
76
+ total_charges: ParseMoneyElement.call(rated_package.at('TotalCharges'))&.last,
77
77
  negotiated_charges: extract_charges(rated_package.xpath('NegotiatedCharges/ItemizedCharges')),
78
78
  weight: BigDecimal(rated_package.at('Weight').text),
79
79
  billing_weight: BigDecimal(rated_package.at('BillingWeight/Weight').text)
@@ -66,7 +66,7 @@ module FriendlyShipping
66
66
 
67
67
  def get_shipment_cost(shipment_xml)
68
68
  total_charges_element = shipment_xml.at('ShipmentResults/ShipmentCharges/TotalCharges')
69
- ParseMoneyElement.call(total_charges_element).last
69
+ ParseMoneyElement.call(total_charges_element)&.last
70
70
  end
71
71
 
72
72
  def get_negotiated_rate(shipment_xml)
@@ -5,8 +5,11 @@ module FriendlyShipping
5
5
  class Ups
6
6
  class SerializeAddressSnippet
7
7
  class << self
8
- def call(xml:, location:)
9
- if location.company_name # Is this a business address?
8
+ def call(xml:, location:, international: false)
9
+ if international
10
+ name = (location.company_name || location.name)[0..34]
11
+ attention_name = location.name
12
+ elsif location.company_name # Is this a business address?
10
13
  name = location.company_name[0..34]
11
14
  attention_name = location.name
12
15
  else
@@ -9,6 +9,7 @@ module FriendlyShipping
9
9
  class SerializeShipmentConfirmRequest
10
10
  class << self
11
11
  # Item options (only necessary for international shipping)
12
+ # ShipTo CompanyName & AttentionName required for international shipping
12
13
  #
13
14
  # @option description [String] A description of the item for the intl. invoice
14
15
  # @option commodity_code [String] Commodity code for the item. See https://www.tariffnumber.com/
@@ -41,7 +42,7 @@ module FriendlyShipping
41
42
  end
42
43
 
43
44
  xml.ShipTo do
44
- SerializeShipmentAddressSnippet.call(xml: xml, location: shipment.destination)
45
+ SerializeShipmentAddressSnippet.call(xml: xml, location: shipment.destination, international: international?(shipment))
45
46
  end
46
47
 
47
48
  # Required element. The company whose account is responsible for the label(s).
@@ -123,7 +124,7 @@ module FriendlyShipping
123
124
 
124
125
  contents_description = shipment.packages.flat_map do |package|
125
126
  package.items.map(&:description)
126
- end.compact.join(', ')
127
+ end.compact.join(', ').slice(0, 50)
127
128
 
128
129
  unless contents_description.empty?
129
130
  xml.Description(contents_description)
@@ -207,7 +208,7 @@ module FriendlyShipping
207
208
 
208
209
  def build_billing_info_node(xml, options, bill_to_consignee: false)
209
210
  billing_options = options.billing_options
210
- if billing_options.bill_third_party
211
+ if billing_options.bill_third_party || bill_to_consignee
211
212
  xml.BillThirdParty do
212
213
  node_type = bill_to_consignee ? :BillThirdPartyConsignee : :BillThirdPartyShipper
213
214
  xml.public_send(node_type) do
@@ -258,9 +259,9 @@ module FriendlyShipping
258
259
 
259
260
  xml.Product do
260
261
  cost = reference_item.cost || Money.new(0, 'USD')
261
- xml.Description(description)
262
+ xml.Description(description&.slice(0, 35))
262
263
  xml.CommodityCode(item_options.commodity_code)
263
- xml.OriginCountryCode(shipment.origin.country.code)
264
+ xml.OriginCountryCode(item_options.country_of_origin || shipment.origin.country.code)
264
265
  xml.Unit do
265
266
  xml.Value(cost * items.length)
266
267
  xml.Number(items.length)
@@ -15,6 +15,8 @@ module FriendlyShipping
15
15
 
16
16
  # @param [RestClient::Exception] cause
17
17
  def parse_message(cause)
18
+ return cause.message unless cause.response
19
+
18
20
  parsed_json = JSON.parse(cause.response.body)
19
21
 
20
22
  if parsed_json['httpCode'].present?
@@ -8,8 +8,10 @@ module FriendlyShipping
8
8
  #
9
9
  # @param [Symbol] box_name The type of box we want to get rates for. Has to be one of the keys
10
10
  # of FriendlyShipping::Services::Usps::CONTAINERS.
11
- # @param [Symbol] return_dimensional_weight Boolean indicating whether the response should include dimensional weight.
12
- # @param [Symbol] return_fees Boolean indicating whether the response should include fees.
11
+ # @param [Boolean] transmit_dimensions Indicate whether the reuqest should include the package dimensionals.
12
+ # @param [Boolean] rectangular Indicate whether the package is rectangular.
13
+ # @param [Boolean] return_dimensional_weight Indicate whether the response should include dimensional weight.
14
+ # @param [Boolean] return_fees Indicate whether the response should include fees.
13
15
  class Usps
14
16
  class RateEstimatePackageOptions < FriendlyShipping::PackageOptions
15
17
  attr_reader :box_name,
@@ -55,12 +57,6 @@ module FriendlyShipping
55
57
  service_code << 'COMMERCIAL' if commercial_pricing
56
58
  service_code.join(' ')
57
59
  end
58
-
59
- private
60
-
61
- def value_or_default(key, default, kwargs)
62
- kwargs.key?(key) ? kwargs.delete(key) : default
63
- end
64
60
  end
65
61
  end
66
62
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UspsInternational
6
+ class ParsePackageRate
7
+ # USPS returns all the info about a rate in a long string with a bit of gibberish.
8
+ ESCAPING_AND_SYMBOLS = /&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
+ # The tags used in the rate node that we get information from.
23
+ SERVICE_CODE_TAG = 'ID'
24
+ SERVICE_NAME_TAG = 'SvcDescription'
25
+ RATE_TAG = 'Postage'
26
+ COMMERCIAL_RATE_TAG = 'CommercialPostage'
27
+ COMMERCIAL_PLUS_RATE_TAG = 'CommercialPlusPostage'
28
+ CURRENCY = Money::Currency.new('USD').freeze
29
+
30
+ class << self
31
+ def call(rate_node, package, package_options)
32
+ # "A mail class identifier for the postage returned. Not necessarily unique within a <Package/>."
33
+ # (from the USPS docs). We save this on the data Hash, but do not use it for identifying shipping methods.
34
+ service_code = rate_node.attributes[SERVICE_CODE_TAG].value
35
+
36
+ # The long string discussed above.
37
+ service_name = rate_node.at(SERVICE_NAME_TAG).text
38
+
39
+ delivery_guarantee = rate_node.at('GuaranteeAvailability')&.text
40
+ delivery_commitment = rate_node.at('SvcCommitments')&.text
41
+
42
+ # Clean up the long string
43
+ service_name.gsub!(SERVICE_NAME_SUBSTITUTIONS, '')
44
+
45
+ commercial_rate_requested_or_rate_is_zero = package_options.commercial_pricing || rate_node.at(RATE_TAG).text.to_d.zero?
46
+ commercial_rate_available = rate_node.at(COMMERCIAL_RATE_TAG) || rate_node.at(COMMERCIAL_PLUS_RATE_TAG)
47
+
48
+ rate_value =
49
+ if commercial_rate_requested_or_rate_is_zero && commercial_rate_available
50
+ rate_node.at(COMMERCIAL_RATE_TAG)&.text&.to_d || rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d
51
+ else
52
+ rate_node.at(RATE_TAG).text.to_d
53
+ end
54
+
55
+ # The rate expressed as a RubyMoney objext
56
+ rate = Money.new(rate_value * CURRENCY.subunit_to_unit, CURRENCY)
57
+
58
+ # Which shipping method does this rate belong to? We first try to match a rate to a shipping method
59
+ shipping_method = SHIPPING_METHODS.find { |sm| sm.service_code == service_code }
60
+
61
+ # Combine all the gathered information in a FriendlyShipping::Rate object.
62
+ # Careful: This rate is only for one package within the shipment, and we get multiple
63
+ # rates per package for the different shipping method/box/hold for pickup combinations.
64
+ FriendlyShipping::Rate.new(
65
+ shipping_method: shipping_method,
66
+ amounts: { package.id => rate },
67
+ data: {
68
+ package: package,
69
+ delivery_commitment: delivery_commitment,
70
+ delivery_guarantee: delivery_guarantee,
71
+ full_mail_service: service_name,
72
+ service_code: service_code,
73
+ }
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/usps/parse_xml_response'
4
+ require 'friendly_shipping/services/usps_international/parse_package_rate'
5
+
6
+ module FriendlyShipping
7
+ module Services
8
+ class UspsInternational
9
+ class ParseRateResponse
10
+ class BoxNotFoundError < StandardError; end
11
+
12
+ class << self
13
+ # Parse a response from USPS' international rating API
14
+ #
15
+ # @param [FriendlyShipping::Request] request The request that was used to obtain this Response
16
+ # @param [FriendlyShipping::Response] response The response that USPS returned
17
+ # @param [Physical::Shipment] shipment The shipment object we're trying to get results for
18
+ # @param [FriendlyShipping::Services::UspsInternational::RateEstimateOptions] options The options we sent with this request
19
+ # @return [Result<ApiResult<Array<FriendlyShipping::Rate>>>] When successfully parsing, an array of rates in a Success Monad.
20
+ def call(request:, response:, shipment:, options:)
21
+ # Filter out error responses and directly return a failure
22
+ parsing_result = FriendlyShipping::Services::Usps::ParseXMLResponse.call(
23
+ request: request,
24
+ response: response,
25
+ expected_root_tag: 'IntlRateV2Response'
26
+ )
27
+ parsing_result.fmap do |xml|
28
+ # Get all the possible rates for each package
29
+ rates_by_package = rates_from_response_node(xml, shipment, options)
30
+
31
+ rates = SHIPPING_METHODS.map do |shipping_method|
32
+ # For every package ...
33
+ matching_rates = rates_by_package.map do |_package, package_rates|
34
+ # ... choose the rate that matches the shipping method for this package
35
+ package_rates.select { |r| r.shipping_method == shipping_method }.first
36
+ end.compact # Some shipping rates are not available for every shipping method.
37
+
38
+ # in this case, go to the next shipping method.
39
+ next if matching_rates.empty?
40
+
41
+ # return one rate for all packages with the amount keys being the package IDs.
42
+ FriendlyShipping::Rate.new(
43
+ amounts: matching_rates.map(&:amounts).reduce({}, :merge),
44
+ shipping_method: shipping_method,
45
+ data: matching_rates.first.data
46
+ )
47
+ end.compact
48
+
49
+ ApiResult.new(
50
+ rates,
51
+ original_request: request,
52
+ original_response: response
53
+ )
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ PACKAGE_NODE_XPATH = '//Package'
60
+ SERVICE_NODE_NAME = 'Service'
61
+
62
+ # Iterate over all packages and parse the rates for each package
63
+ #
64
+ # @param [Nokogiri::XML::Node] xml The XML document containing packages and rates
65
+ # @param [Physical::Shipment] shipment The shipment we're trying to get rates for
66
+ #
67
+ # @return [Hash<Physical::Package => Array<FriendlyShipping::Rate>>]
68
+ def rates_from_response_node(xml, shipment, options)
69
+ xml.xpath(PACKAGE_NODE_XPATH).each_with_object({}) do |package_node, result|
70
+ package_id = package_node['ID']
71
+ corresponding_package = shipment.packages[package_id.to_i]
72
+ package_options = options.options_for_package(corresponding_package)
73
+ # There should always be a package in the original shipment that corresponds to the package ID
74
+ # in the USPS response.
75
+ raise BoxNotFoundError if corresponding_package.nil?
76
+
77
+ result[corresponding_package] = package_node.xpath(SERVICE_NODE_NAME).map do |service_node|
78
+ ParsePackageRate.call(service_node, corresponding_package, package_options)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/usps_international/rate_estimate_package_options'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ # Option container for rating a shipment via USPS
8
+ #
9
+ # Context: The shipment object we're trying to get results for
10
+ # USPS returns rates on a package-by-package basis, so the options for obtaining rates are
11
+ # set on the [FriendlyShipping/RateEstimateObject] hash. The possible options are:
12
+
13
+ # @param [Physical::ShippingMethod] shipping_method The shipping method ("service" in USPS parlance) we want
14
+ # to get rates for.
15
+ # @param [Boolean] commercial_pricing Whether we prefer commercial pricing results or retail results
16
+ # @param [Boolean] hold_for_pickup Whether we want a rate with Hold For Pickup Service
17
+ class UspsInternational
18
+ class RateEstimateOptions < FriendlyShipping::ShipmentOptions
19
+ def initialize(
20
+ package_options_class: FriendlyShipping::Services::UspsInternational::RateEstimatePackageOptions,
21
+ **kwargs
22
+ )
23
+ super(**kwargs.merge(package_options_class: package_options_class))
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/ups/rate_estimate_package_options'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ # Options for one package when rating
8
+ #
9
+ # @param [Symbol] box_name The type of box we want to get rates for.
10
+ # @param [Boolean] commercial_pricing Indicate whether the response should include commercial pricing rates.
11
+ # @param [Boolean] commercial_plus_pricing Indicate whether the response should include commercial pluse pricing rates.
12
+ # @param [Symbol] container_code Indicate the type of container of the package.
13
+ # @param [Symbol] mail_type Indicate the type of mail to estimate rates.
14
+ # @param [Boolean] rectangular Indicate whether the package is rectangular.
15
+ # @param [FriendlyShipping::ShippingMethod] shipping_method Describe the requested shipping method.
16
+ # @param [Symbol] transmit_dimensions Indicate whether the request should include the package dimensionals.
17
+ class UspsInternational
18
+ class RateEstimatePackageOptions < FriendlyShipping::PackageOptions
19
+ attr_reader :box_name,
20
+ :commercial_pricing,
21
+ :commercial_plus_pricing,
22
+ :container,
23
+ :mail_type,
24
+ :rectangular,
25
+ :shipping_method,
26
+ :transmit_dimensions
27
+
28
+ def initialize(**kwargs)
29
+ container_code = value_or_default(:container, :variable, kwargs) || :variable
30
+ mail_type_code = value_or_default(:mail_type, :all, kwargs) || :all
31
+
32
+ @box_name = value_or_default(:box_name, :variable, kwargs)
33
+ @commercial_pricing = value_or_default(:commercial_pricing, false, kwargs) ? 'Y' : 'N'
34
+ @commercial_plus_pricing = value_or_default(:commercial_plus_pricing, false, kwargs) ? 'Y' : 'N'
35
+ @container = CONTAINERS.fetch(container_code)
36
+ @mail_type = MAIL_TYPES.fetch(mail_type_code)
37
+ @rectangular = @container.eql?("ROLL") ? false : value_or_default(:rectangular, true, kwargs)
38
+ @shipping_method = kwargs.delete(:shipping_method)
39
+ @transmit_dimensions = value_or_default(:transmit_dimensions, true, kwargs)
40
+ super(**kwargs)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/usps/machinable_package'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ class UspsInternational
8
+ class SerializeRateRequest
9
+ class << self
10
+ # @param [Physical::Shipment] shipment The shipment we want to get rates for
11
+ # shipment.packages[0].properties[:box_name] Can be :variable or a
12
+ # flat rate container defined in CONTAINERS.
13
+ # @param [String] login The USPS login code
14
+ # @param [FriendlyShipping::Services::UspsInternational::RateEstimateOptions] options The options
15
+ # object to use with this request.
16
+ # @return Array<[FriendlyShipping::Rate]> A set of Rates that this package may be sent with
17
+ def call(shipment:, login:, options:)
18
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
19
+ xml.IntlRateV2Request('USERID' => login) do
20
+ xml.Revision("2")
21
+ shipment.packages.each_with_index do |package, index|
22
+ xml.Package('ID' => index) do
23
+ xml.Pounds(pounds_for(package))
24
+ xml.Ounces(ounces_for(package))
25
+ xml.Machinable(machinable(package))
26
+ package_options = options.options_for_package(package)
27
+ xml.MailType(package_options.mail_type)
28
+ xml.ValueOfContents(package.items_value)
29
+ xml.Country(shipment.destination.country)
30
+ xml.Container(package_options.container)
31
+ if package_options.transmit_dimensions && package_options.container == 'VARIABLE'
32
+ xml.Width("%<width>0.2f" % { width: package.width.convert_to(:inches).value.to_f })
33
+ xml.Length("%<length>0.2f" % { length: package.length.convert_to(:inches).value.to_f })
34
+ xml.Height("%<height>0.2f" % { height: package.height.convert_to(:inches).value.to_f })
35
+
36
+ # When girth is present, the package is treated as non-rectangular
37
+ # when calculating dimensional weight. This results in a smaller
38
+ # dimensional weight than a rectangular package would have.
39
+ unless package_options.rectangular
40
+ xml.Girth("%<girth>0.2f" % { girth: girth(package) })
41
+ end
42
+ xml.CommercialFlag(package_options.commercial_pricing)
43
+ xml.CommercialPlusFlag(package_options.commercial_plus_pricing)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ xml_builder.to_xml
50
+ end
51
+
52
+ private
53
+
54
+ def machinable(package)
55
+ FriendlyShipping::Services::Usps::MachinablePackage.new(package).machinable? ? 'true' : 'false'
56
+ end
57
+
58
+ def ounces_for(package)
59
+ ounces = package.weight.convert_to(:ounces).value.to_f.round(2).ceil
60
+ ounces == 16 ? 15.999 : [ounces, 1].max
61
+ end
62
+
63
+ def pounds_for(package)
64
+ package.weight.convert_to(:pounds).value.to_f.round(2).ceil
65
+ end
66
+
67
+ def girth(package)
68
+ width, length = package.dimensions.sort.first(2)
69
+ (width.scale(2) + length.scale(2)).convert_to(:inches).value.to_f
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UspsInternational
6
+ SHIPPING_METHODS = [
7
+ ["1", "Priority Mail Express International"],
8
+ ["2", "Priority Mail International"],
9
+ ["4", "Global Express Guaranteed; (GXG)"],
10
+ ["5", "Global Express Guaranteed; Document"],
11
+ ["6", "Global Express Guarantee; Non-Document Rectangular"],
12
+ ["7", "Global Express Guaranteed; Non-Document Non-Rectangular"],
13
+ ["8", "Priority Mail International; Flat Rate Envelope"],
14
+ ["9", "Priority Mail International; Medium Flat Rate Box"],
15
+ ["10", "Priority Mail Express International; Flat Rate Envelope"],
16
+ ["11", "Priority Mail International; Large Flat Rate Box"],
17
+ ["12", "USPS GXG; Envelopes"],
18
+ ["13", "First-Class Mail; International Letter"],
19
+ ["14", "First-Class Mail; International Large Envelope"],
20
+ ["15", "First-Class Package International Service"],
21
+ ["16", "Priority Mail International; Small Flat Rate Box"],
22
+ ["17", "Priority Mail Express International; Legal Flat Rate Envelope"],
23
+ ["18", "Priority Mail International; Gift Card Flat Rate Envelope"],
24
+ ["19", "Priority Mail International; Window Flat Rate Envelope"],
25
+ ["20", "Priority Mail International; Small Flat Rate Envelope"],
26
+ ["28", "Airmail M-Bag"]
27
+ ].map do |code, name|
28
+ FriendlyShipping::ShippingMethod.new(
29
+ origin_countries: [Carmen::Country.coded('US')],
30
+ name: name,
31
+ service_code: code,
32
+ domestic: false,
33
+ international: true,
34
+ )
35
+ end.freeze
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/http_client'
4
+ require 'friendly_shipping/services/usps_international/shipping_methods'
5
+ require 'friendly_shipping/services/usps_international/serialize_rate_request'
6
+ require 'friendly_shipping/services/usps_international/parse_rate_response'
7
+ require 'friendly_shipping/services/usps_international/rate_estimate_options'
8
+
9
+ module FriendlyShipping
10
+ module Services
11
+ class UspsInternational
12
+ include Dry::Monads[:result]
13
+
14
+ attr_reader :test, :login, :client
15
+
16
+ CONTAINERS = {
17
+ rectanglular: 'RECTANGULAR',
18
+ roll: 'ROLL',
19
+ variable: 'VARIABLE'
20
+ }.freeze
21
+
22
+ MAIL_TYPES = {
23
+ all: 'ALL',
24
+ airmail: 'AIRMAIL MBAG',
25
+ envelope: 'ENVELOPE',
26
+ flat_rate: 'FLATRATE',
27
+ letter: 'LETTER',
28
+ large_envelope: 'LARGEENVELOPE',
29
+ package: 'PACKAGE',
30
+ post_cards: 'POSTCARDS'
31
+ }.freeze
32
+
33
+ TEST_URL = 'https://stg-secure.shippingapis.com/ShippingAPI.dll'
34
+ LIVE_URL = 'https://secure.shippingapis.com/ShippingAPI.dll'
35
+
36
+ RESOURCES = {
37
+ rates: 'IntlRateV2',
38
+ }.freeze
39
+
40
+ def initialize(login:, test: true, client: HttpClient.new)
41
+ @login = login
42
+ @test = test
43
+ @client = client
44
+ end
45
+
46
+ # Get rate estimates from USPS International
47
+ #
48
+ # @param [Physical::Shipment] shipment
49
+ # @param [FriendlyShipping::Services::UspsInternational::RateEstimateOptions] options What options
50
+ # to use for this rate estimate call
51
+ #
52
+ # @return [Result<Array<FriendlyShipping::Rate>>] When successfully parsing, an array of rates in a Success Monad.
53
+ # When the parsing is not successful or USPS can't give us rates, a Failure monad containing something that
54
+ # can be serialized into an error message using `to_s`.
55
+ def rate_estimates(shipment, options: RateEstimateOptions.new, debug: false)
56
+ rate_request_xml = SerializeRateRequest.call(shipment: shipment, login: login, options: options)
57
+ request = build_request(api: :rates, xml: rate_request_xml, debug: debug)
58
+ client.post(request).bind do |response|
59
+ ParseRateResponse.call(response: response, request: request, shipment: shipment, options: options)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def build_request(api:, xml:, debug:)
66
+ FriendlyShipping::Request.new(
67
+ url: base_url,
68
+ http_method: "POST",
69
+ body: "API=#{RESOURCES[api]}&XML=#{CGI.escape xml}",
70
+ readable_body: xml,
71
+ debug: debug
72
+ )
73
+ end
74
+
75
+ def base_url
76
+ test ? TEST_URL : LIVE_URL
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FriendlyShipping
4
- VERSION = "0.7.3"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -20,6 +20,7 @@ require "friendly_shipping/services/ship_engine"
20
20
  require "friendly_shipping/services/ups"
21
21
  require "friendly_shipping/services/ups_freight"
22
22
  require "friendly_shipping/services/usps"
23
+ require "friendly_shipping/services/usps_international"
23
24
 
24
25
  module FriendlyShipping
25
26
  end
metadata CHANGED
@@ -1,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: friendly_shipping
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Meyerhoff
8
+ - Matthew Bass
8
9
  autorequire:
9
10
  bindir: exe
10
11
  cert_chain: []
11
- date: 2023-01-30 00:00:00.000000000 Z
12
+ date: 2023-04-18 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: dry-monads
@@ -286,6 +287,8 @@ files:
286
287
  - lib/friendly_shipping/services/ship_engine.rb
287
288
  - lib/friendly_shipping/services/ship_engine/bad_request.rb
288
289
  - lib/friendly_shipping/services/ship_engine/bad_request_handler.rb
290
+ - lib/friendly_shipping/services/ship_engine/label_customs_options.rb
291
+ - lib/friendly_shipping/services/ship_engine/label_item_options.rb
289
292
  - lib/friendly_shipping/services/ship_engine/label_options.rb
290
293
  - lib/friendly_shipping/services/ship_engine/label_package_options.rb
291
294
  - lib/friendly_shipping/services/ship_engine/parse_carrier_response.rb
@@ -372,6 +375,13 @@ files:
372
375
  - lib/friendly_shipping/services/usps/serialize_time_in_transit_request.rb
373
376
  - lib/friendly_shipping/services/usps/shipping_methods.rb
374
377
  - lib/friendly_shipping/services/usps/timing_options.rb
378
+ - lib/friendly_shipping/services/usps_international.rb
379
+ - lib/friendly_shipping/services/usps_international/parse_package_rate.rb
380
+ - lib/friendly_shipping/services/usps_international/parse_rate_response.rb
381
+ - lib/friendly_shipping/services/usps_international/rate_estimate_options.rb
382
+ - lib/friendly_shipping/services/usps_international/rate_estimate_package_options.rb
383
+ - lib/friendly_shipping/services/usps_international/serialize_rate_request.rb
384
+ - lib/friendly_shipping/services/usps_international/shipping_methods.rb
375
385
  - lib/friendly_shipping/shipment_options.rb
376
386
  - lib/friendly_shipping/shipping_method.rb
377
387
  - lib/friendly_shipping/timing.rb