friendly_shipping 0.7.3 → 0.8.1

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