friendly_shipping 0.7.3 → 0.8.0

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