friendly_shipping 0.7.3 → 0.8.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.env.template +4 -0
  3. data/.env.test +4 -0
  4. data/CHANGELOG.md +23 -0
  5. data/friendly_shipping.gemspec +1 -1
  6. data/lib/friendly_shipping/package_options.rb +4 -0
  7. data/lib/friendly_shipping/rate.rb +1 -1
  8. data/lib/friendly_shipping/services/ship_engine/label_customs_options.rb +25 -0
  9. data/lib/friendly_shipping/services/ship_engine/label_item_options.rb +27 -0
  10. data/lib/friendly_shipping/services/ship_engine/label_options.rb +5 -1
  11. data/lib/friendly_shipping/services/ship_engine/label_package_options.rb +2 -1
  12. data/lib/friendly_shipping/services/ship_engine/serialize_label_shipment.rb +37 -0
  13. data/lib/friendly_shipping/services/ship_engine_ltl/bad_request.rb +9 -0
  14. data/lib/friendly_shipping/services/ship_engine_ltl/bad_request_handler.rb +33 -0
  15. data/lib/friendly_shipping/services/ship_engine_ltl/item_options.rb +31 -0
  16. data/lib/friendly_shipping/services/ship_engine_ltl/package_options.rb +15 -0
  17. data/lib/friendly_shipping/services/ship_engine_ltl/parse_carrier_response.rb +49 -0
  18. data/lib/friendly_shipping/services/ship_engine_ltl/parse_quote_response.rb +72 -0
  19. data/lib/friendly_shipping/services/ship_engine_ltl/quote_options.rb +34 -0
  20. data/lib/friendly_shipping/services/ship_engine_ltl/serialize_packages.rb +41 -0
  21. data/lib/friendly_shipping/services/ship_engine_ltl/serialize_quote_request.rb +109 -0
  22. data/lib/friendly_shipping/services/ship_engine_ltl.rb +133 -0
  23. data/lib/friendly_shipping/services/ups/label_item_options.rb +4 -1
  24. data/lib/friendly_shipping/services/ups/label_package_options.rb +7 -3
  25. data/lib/friendly_shipping/services/ups/parse_rate_response.rb +3 -3
  26. data/lib/friendly_shipping/services/ups/parse_shipment_accept_response.rb +1 -1
  27. data/lib/friendly_shipping/services/ups/serialize_address_snippet.rb +5 -2
  28. data/lib/friendly_shipping/services/ups/serialize_package_node.rb +11 -1
  29. data/lib/friendly_shipping/services/ups/serialize_shipment_confirm_request.rb +11 -7
  30. data/lib/friendly_shipping/services/ups_freight/api_error.rb +2 -0
  31. data/lib/friendly_shipping/services/ups_freight/generate_location_hash.rb +10 -6
  32. data/lib/friendly_shipping/services/usps/parse_package_rate.rb +2 -1
  33. data/lib/friendly_shipping/services/usps/parse_time_in_transit_response.rb +6 -2
  34. data/lib/friendly_shipping/services/usps/rate_estimate_package_options.rb +4 -8
  35. data/lib/friendly_shipping/services/usps/shipping_methods.rb +4 -2
  36. data/lib/friendly_shipping/services/usps_international/parse_package_rate.rb +81 -0
  37. data/lib/friendly_shipping/services/usps_international/parse_rate_response.rb +86 -0
  38. data/lib/friendly_shipping/services/usps_international/rate_estimate_options.rb +28 -0
  39. data/lib/friendly_shipping/services/usps_international/rate_estimate_package_options.rb +45 -0
  40. data/lib/friendly_shipping/services/usps_international/serialize_rate_request.rb +75 -0
  41. data/lib/friendly_shipping/services/usps_international/shipping_methods.rb +38 -0
  42. data/lib/friendly_shipping/services/usps_international.rb +80 -0
  43. data/lib/friendly_shipping/version.rb +1 -1
  44. data/lib/friendly_shipping.rb +2 -0
  45. metadata +24 -3
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads'
4
+ require 'friendly_shipping/http_client'
5
+ require 'friendly_shipping/services/ship_engine_ltl/bad_request_handler'
6
+ require 'friendly_shipping/services/ship_engine_ltl/parse_carrier_response'
7
+ require 'friendly_shipping/services/ship_engine_ltl/parse_quote_response'
8
+ require 'friendly_shipping/services/ship_engine_ltl/serialize_packages'
9
+ require 'friendly_shipping/services/ship_engine_ltl/serialize_quote_request'
10
+ require 'friendly_shipping/services/ship_engine_ltl/quote_options'
11
+ require 'friendly_shipping/services/ship_engine_ltl/package_options'
12
+ require 'friendly_shipping/services/ship_engine_ltl/item_options'
13
+
14
+ module FriendlyShipping
15
+ module Services
16
+ class ShipEngineLTL
17
+ include Dry::Monads::Result::Mixin
18
+
19
+ API_BASE = "https://api.shipengine.com/v-beta/ltl/"
20
+ API_PATHS = {
21
+ connections: "connections",
22
+ carriers: "carriers",
23
+ quotes: "quotes"
24
+ }.freeze
25
+
26
+ def initialize(
27
+ token:,
28
+ test: true,
29
+ client: FriendlyShipping::HttpClient.new(
30
+ error_handler: FriendlyShipping::Services::ShipEngineLTL::BadRequestHandler
31
+ )
32
+ )
33
+ @token = token
34
+ @test = test
35
+ @client = client
36
+ end
37
+
38
+ # Get configured LTL carriers from ShipEngine
39
+ #
40
+ # @return [Result<ApiResult<Array<Carrier>>>] LTL carriers configured in your account
41
+ def carriers(debug: false)
42
+ request = FriendlyShipping::Request.new(
43
+ url: API_BASE + API_PATHS[:carriers],
44
+ headers: request_headers,
45
+ debug: debug
46
+ )
47
+ client.get(request).bind do |response|
48
+ ParseCarrierResponse.call(request: request, response: response)
49
+ end
50
+ end
51
+
52
+ # Connect an LTL carrier to ShipEngine
53
+ #
54
+ # @param [Hash] credentials The carrier's connection information
55
+ # @param [String] scac Standard Carrier Alpha Code
56
+ #
57
+ # @return [Result<ApiResult<Hash>>] The unique carrier ID assigned by ShipEngine
58
+ def connect_carrier(credentials, scac, debug: false)
59
+ request = FriendlyShipping::Request.new(
60
+ url: API_BASE + API_PATHS[:connections] + "/#{scac}",
61
+ body: { credentials: credentials }.to_json,
62
+ headers: request_headers,
63
+ debug: debug
64
+ )
65
+ client.post(request).bind do |response|
66
+ Success(
67
+ ApiResult.new(
68
+ JSON.parse(response.body),
69
+ original_request: request,
70
+ original_response: response
71
+ )
72
+ )
73
+ end
74
+ end
75
+
76
+ # Update an existing LTL carrier in ShipEngine
77
+ #
78
+ # @param [Hash] credentials The carrier's connection information
79
+ # @param [String] scac Standard Carrier Alpha Code
80
+ # @param [String] carrier_id The carrier ID from ShipEngine that you want to update
81
+ #
82
+ # @return [Result<ApiResult<Hash>>] The unique carrier ID assigned by ShipEngine
83
+ def update_carrier(credentials, scac, carrier_id, debug: false)
84
+ request = FriendlyShipping::Request.new(
85
+ url: API_BASE + API_PATHS[:connections] + "/#{scac}/#{carrier_id}",
86
+ body: { credentials: credentials }.to_json,
87
+ headers: request_headers,
88
+ debug: debug
89
+ )
90
+ client.put(request).bind do |response|
91
+ Success(
92
+ ApiResult.new(
93
+ JSON.parse(response.body),
94
+ original_request: request,
95
+ original_response: response
96
+ )
97
+ )
98
+ end
99
+ end
100
+
101
+ # Request an LTL price quote from ShipEngine
102
+ #
103
+ # @param [String] carrier_id The carrier ID from ShipEngine that you want to quote against
104
+ # @param [Physical::Shipment] shipment The shipment to quote
105
+ # @param [FriendlyShipping::Services::ShipEngineLTL::QuoteOptions] options The options for the quote
106
+ #
107
+ # @return [Result<ApiResult<Hash>>] The price quote from ShipEngine
108
+ def request_quote(carrier_id, shipment, options, debug: false)
109
+ request = FriendlyShipping::Request.new(
110
+ url: API_BASE + API_PATHS[:quotes] + "/#{carrier_id}",
111
+ http_method: "POST",
112
+ body: SerializeQuoteRequest.call(shipment: shipment, options: options).to_json,
113
+ headers: request_headers,
114
+ debug: debug
115
+ )
116
+ client.post(request).bind do |response|
117
+ ParseQuoteResponse.call(request: request, response: response)
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ attr_reader :token, :test, :client
124
+
125
+ def request_headers
126
+ {
127
+ content_type: :json,
128
+ "api-key": token
129
+ }
130
+ end
131
+ end
132
+ end
133
+ 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
@@ -11,8 +11,10 @@ module FriendlyShipping
11
11
  # values are _reference number values_. Example: `{ reference_numbers: { xn: 'my_reference_1 }`
12
12
  # @option delivery_confirmation [Symbol] Can be set to any key from PACKAGE_DELIVERY_CONFIRMATION_CODES.
13
13
  # Only possible for domestic shipments or shipments between the US and Puerto Rico.
14
- # @option shipper_release [Boolean] Indicates that the package may be released by driver without a signature from the
15
- # consignee. Default: false
14
+ # @option shipper_release [Boolean] Indicates that the package may be released by driver without a signature from
15
+ # the consignee. Default: false
16
+ # @option declared_value [Boolean] When true, declared value (calculated as the sum of all items in the shipment)
17
+ # will be included in the request. Default: false
16
18
  class LabelPackageOptions < FriendlyShipping::PackageOptions
17
19
  PACKAGE_DELIVERY_CONFIRMATION_CODES = {
18
20
  delivery_confirmation: 1,
@@ -20,17 +22,19 @@ module FriendlyShipping
20
22
  delivery_confirmation_adult_signature_required: 3
21
23
  }.freeze
22
24
 
23
- attr_reader :reference_numbers, :shipper_release
25
+ attr_reader :reference_numbers, :shipper_release, :declared_value
24
26
 
25
27
  def initialize(
26
28
  reference_numbers: {},
27
29
  delivery_confirmation: nil,
28
30
  shipper_release: false,
31
+ declared_value: false,
29
32
  **kwargs
30
33
  )
31
34
  @reference_numbers = reference_numbers
32
35
  @delivery_confirmation = delivery_confirmation
33
36
  @shipper_release = shipper_release
37
+ @declared_value = declared_value
34
38
  super(**kwargs.merge(item_options_class: LabelItemOptions))
35
39
  end
36
40
 
@@ -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
@@ -10,7 +10,8 @@ module FriendlyShipping
10
10
  reference_numbers: {},
11
11
  delivery_confirmation_code: nil,
12
12
  shipper_release: false,
13
- transmit_dimensions: true
13
+ transmit_dimensions: true,
14
+ declared_value: false
14
15
  )
15
16
  xml.Package do
16
17
  xml.PackagingType do
@@ -49,6 +50,15 @@ module FriendlyShipping
49
50
  xml.DCISType(delivery_confirmation_code)
50
51
  end
51
52
  end
53
+ if declared_value
54
+ xml.DeclaredValue do
55
+ xml.CurrencyCode('USD')
56
+ monetary_value = package.items.inject(Money.new(0, 'USD')) do |package_sum, item|
57
+ package_sum + (item.cost || Money.new(0, 'USD'))
58
+ end
59
+ xml.MonetaryValue(monetary_value)
60
+ end
61
+ end
52
62
  end
53
63
 
54
64
  reference_numbers.each do |reference_code, reference_number|
@@ -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).
@@ -105,6 +106,8 @@ module FriendlyShipping
105
106
  xml.SoldTo do
106
107
  sold_to_location = options.sold_to || shipment.destination
107
108
  SerializeShipmentAddressSnippet.call(xml: xml, location: sold_to_location)
109
+ xml.AccountNumber(options.sold_to.account_number) if options.sold_to.try(:account_number).present?
110
+ xml.TaxIdentificationNumber(options.sold_to.tax_id_number) if options.sold_to.try(:tax_id_number).present?
108
111
  end
109
112
  end
110
113
 
@@ -123,7 +126,7 @@ module FriendlyShipping
123
126
 
124
127
  contents_description = shipment.packages.flat_map do |package|
125
128
  package.items.map(&:description)
126
- end.compact.join(', ')
129
+ end.compact.uniq.join(', ').slice(0, 50)
127
130
 
128
131
  unless contents_description.empty?
129
132
  xml.Description(contents_description)
@@ -159,7 +162,8 @@ module FriendlyShipping
159
162
  package: package,
160
163
  reference_numbers: reference_numbers,
161
164
  delivery_confirmation_code: delivery_confirmation_code,
162
- shipper_release: package_options.shipper_release
165
+ shipper_release: package_options.shipper_release,
166
+ declared_value: package_options.declared_value
163
167
  )
164
168
  end
165
169
  end
@@ -207,7 +211,7 @@ module FriendlyShipping
207
211
 
208
212
  def build_billing_info_node(xml, options, bill_to_consignee: false)
209
213
  billing_options = options.billing_options
210
- if billing_options.bill_third_party
214
+ if billing_options.bill_third_party || bill_to_consignee
211
215
  xml.BillThirdParty do
212
216
  node_type = bill_to_consignee ? :BillThirdPartyConsignee : :BillThirdPartyShipper
213
217
  xml.public_send(node_type) do
@@ -258,11 +262,11 @@ module FriendlyShipping
258
262
 
259
263
  xml.Product do
260
264
  cost = reference_item.cost || Money.new(0, 'USD')
261
- xml.Description(description)
265
+ xml.Description(description&.slice(0, 35))
262
266
  xml.CommodityCode(item_options.commodity_code)
263
- xml.OriginCountryCode(shipment.origin.country.code)
267
+ xml.OriginCountryCode(item_options.country_of_origin || shipment.origin.country.code)
264
268
  xml.Unit do
265
- xml.Value(cost * items.length)
269
+ xml.Value(cost)
266
270
  xml.Number(items.length)
267
271
  xml.UnitOfMeasurement do
268
272
  xml.Code(item_options.product_unit_of_measure_code)
@@ -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?
@@ -7,17 +7,17 @@ module FriendlyShipping
7
7
  class << self
8
8
  def call(location:)
9
9
  {
10
- Name: location.company_name.presence || location.name,
10
+ Name: truncate(location.company_name.presence || location.name),
11
11
  Address: {
12
12
  AddressLine: address_line(location),
13
- City: location.city,
13
+ City: truncate(location.city, length: 29),
14
14
  StateProvinceCode: location.region&.code,
15
15
  PostalCode: location.zip,
16
16
  CountryCode: location.country&.code
17
17
  },
18
- AttentionName: location.name,
18
+ AttentionName: truncate(location.name),
19
19
  Phone: {
20
- Number: location.phone
20
+ Number: truncate(location.phone, length: 14)
21
21
  }.compact.presence
22
22
  }.compact
23
23
  end
@@ -29,8 +29,12 @@ module FriendlyShipping
29
29
  location.address1,
30
30
  location.address2,
31
31
  location.address3
32
- ].compact.reject(&:empty?)
33
- address_lines.size > 1 ? address_lines : address_lines.first
32
+ ].compact.reject(&:empty?).map { |e| truncate(e) }
33
+ address_lines.size > 1 ? address_lines : truncate(address_lines.first)
34
+ end
35
+
36
+ def truncate(value, length: 35)
37
+ value && value[0..(length - 1)]
34
38
  end
35
39
  end
36
40
  end
@@ -94,7 +94,8 @@ module FriendlyShipping
94
94
 
95
95
  rate_value =
96
96
  if commercial_rate_requested_or_rate_is_zero && commercial_rate_available
97
- rate_node.at(COMMERCIAL_RATE_TAG)&.text&.to_d || rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d
97
+ commercial_rate = rate_node.at(COMMERCIAL_RATE_TAG)&.text.to_d
98
+ commercial_rate.zero? ? rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d : commercial_rate
98
99
  else
99
100
  rate_node.at(RATE_TAG).text.to_d
100
101
  end
@@ -106,10 +106,12 @@ module FriendlyShipping
106
106
  # “0” = All Mail Classes
107
107
  # “1” = Priority Mail Express
108
108
  # “2” = Priority Mail
109
- # “3” = First Class Mail
109
+ # “3” = First-Class - replaced by Ground Advantage (up to 15.999 oz)
110
110
  # “4” = Marketing Mail
111
111
  # “5” = Periodicals
112
112
  # “6” = Package Services
113
+ # “7” = Parcel Select Ground - replaced by Ground Advantage (1-70 lbs)
114
+ # “9” = Ground Advantage (1-70 lbs)
113
115
  #
114
116
  # However, no shipping methods really map to "Marketing Mail" or "Periodicals".
115
117
  # This will likely be somewhat more work in the future.
@@ -117,7 +119,9 @@ module FriendlyShipping
117
119
  '1' => 'Priority Mail Express',
118
120
  '2' => 'Priority Mail',
119
121
  '3' => 'First-Class',
120
- '6' => 'Package Services'
122
+ '6' => 'Package Services',
123
+ '7' => 'Parcel Select Ground',
124
+ '9' => 'Ground Advantage'
121
125
  }.freeze
122
126
 
123
127
  # This code carries a few details about the shipment:
@@ -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
@@ -35,11 +35,13 @@ module FriendlyShipping
35
35
  hold_for_pickup: '2',
36
36
  sunday_holiday_delivery: '23'
37
37
  },
38
- priority_mail_cubic: '999'
38
+ priority_mail_cubic: '999',
39
+ ground_advantage: '1058'
39
40
  }.freeze
40
41
 
41
42
  SHIPPING_METHODS = [
42
43
  ['FIRST CLASS', 'First-Class'],
44
+ ['GROUND ADVANTAGE', 'Ground Advantage', CLASS_IDS[:ground_advantage]],
43
45
  ['PACKAGE SERVICES', 'Package Services'],
44
46
  ['PRIORITY', 'Priority Mail'],
45
47
  ['PRIORITY MAIL EXPRESS', 'Priority Mail Express', CLASS_IDS[:priority_mail_express].values],
@@ -55,7 +57,7 @@ module FriendlyShipping
55
57
  service_code: code,
56
58
  domestic: true,
57
59
  international: false,
58
- data: { class_ids: class_ids }
60
+ data: { class_ids: Array(class_ids) }
59
61
  )
60
62
  end.freeze
61
63
  end
@@ -0,0 +1,81 @@
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
+ commercial_rate = rate_node.at(COMMERCIAL_RATE_TAG)&.text.to_d
51
+ commercial_rate.zero? ? rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d : commercial_rate
52
+ else
53
+ rate_node.at(RATE_TAG).text.to_d
54
+ end
55
+
56
+ # The rate expressed as a RubyMoney objext
57
+ rate = Money.new(rate_value * CURRENCY.subunit_to_unit, CURRENCY)
58
+
59
+ # Which shipping method does this rate belong to? We first try to match a rate to a shipping method
60
+ shipping_method = SHIPPING_METHODS.find { |sm| sm.service_code == service_code }
61
+
62
+ # Combine all the gathered information in a FriendlyShipping::Rate object.
63
+ # Careful: This rate is only for one package within the shipment, and we get multiple
64
+ # rates per package for the different shipping method/box/hold for pickup combinations.
65
+ FriendlyShipping::Rate.new(
66
+ shipping_method: shipping_method,
67
+ amounts: { package.id => rate },
68
+ data: {
69
+ package: package,
70
+ delivery_commitment: delivery_commitment,
71
+ delivery_guarantee: delivery_guarantee,
72
+ full_mail_service: service_name,
73
+ service_code: service_code,
74
+ }
75
+ )
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ 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