friendly_shipping 0.5.2 → 0.6.3

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop-relaxed.yml +3 -4
  4. data/.rubocop.yml +9 -0
  5. data/CHANGELOG.md +32 -1
  6. data/friendly_shipping.gemspec +12 -12
  7. data/lib/friendly_shipping/api_failure.rb +2 -15
  8. data/lib/friendly_shipping/http_client.rb +16 -9
  9. data/lib/friendly_shipping/services/ship_engine/bad_request_handler.rb +15 -3
  10. data/lib/friendly_shipping/services/ups/parse_address_classification_response.rb +5 -2
  11. data/lib/friendly_shipping/services/ups/parse_address_validation_response.rb +5 -2
  12. data/lib/friendly_shipping/services/ups/parse_city_state_lookup_response.rb +5 -2
  13. data/lib/friendly_shipping/services/ups/parse_money_element.rb +2 -2
  14. data/lib/friendly_shipping/services/ups/parse_rate_response.rb +5 -1
  15. data/lib/friendly_shipping/services/ups/parse_shipment_accept_response.rb +5 -1
  16. data/lib/friendly_shipping/services/ups/parse_shipment_confirm_response.rb +5 -1
  17. data/lib/friendly_shipping/services/ups/parse_time_in_transit_response.rb +6 -2
  18. data/lib/friendly_shipping/services/ups/parse_void_shipment_response.rb +5 -1
  19. data/lib/friendly_shipping/services/ups/parse_xml_response.rb +16 -7
  20. data/lib/friendly_shipping/services/ups_freight.rb +44 -13
  21. data/lib/friendly_shipping/services/ups_freight/generate_delivery_options_hash.rb +21 -0
  22. data/lib/friendly_shipping/services/ups_freight/generate_document_options_hash.rb +28 -0
  23. data/lib/friendly_shipping/services/ups_freight/generate_email_options_hash.rb +25 -0
  24. data/lib/friendly_shipping/services/ups_freight/generate_freight_rate_request_hash.rb +2 -10
  25. data/lib/friendly_shipping/services/ups_freight/generate_freight_ship_request_hash.rb +81 -0
  26. data/lib/friendly_shipping/services/ups_freight/generate_location_hash.rb +5 -2
  27. data/lib/friendly_shipping/services/ups_freight/generate_pickup_options_hash.rb +21 -0
  28. data/lib/friendly_shipping/services/ups_freight/generate_pickup_request_hash.rb +31 -0
  29. data/lib/friendly_shipping/services/ups_freight/label_delivery_options.rb +29 -0
  30. data/lib/friendly_shipping/services/ups_freight/label_document_options.rb +56 -0
  31. data/lib/friendly_shipping/services/ups_freight/label_email_options.rb +40 -0
  32. data/lib/friendly_shipping/services/ups_freight/label_item_options.rb +10 -0
  33. data/lib/friendly_shipping/services/ups_freight/label_options.rb +37 -0
  34. data/lib/friendly_shipping/services/ups_freight/label_package_options.rb +10 -0
  35. data/lib/friendly_shipping/services/ups_freight/label_pickup_options.rb +29 -0
  36. data/lib/friendly_shipping/services/ups_freight/parse_freight_label_response.rb +57 -0
  37. data/lib/friendly_shipping/services/ups_freight/parse_freight_rate_response.rb +29 -32
  38. data/lib/friendly_shipping/services/ups_freight/parse_shipment_document.rb +24 -0
  39. data/lib/friendly_shipping/services/ups_freight/pickup_request_options.rb +29 -0
  40. data/lib/friendly_shipping/services/ups_freight/rates_options.rb +3 -6
  41. data/lib/friendly_shipping/services/ups_freight/restful_api_error_handler.rb +30 -0
  42. data/lib/friendly_shipping/services/ups_freight/shipment_document.rb +21 -0
  43. data/lib/friendly_shipping/services/ups_freight/shipment_information.rb +35 -0
  44. data/lib/friendly_shipping/services/usps/parse_address_validation_response.rb +5 -1
  45. data/lib/friendly_shipping/services/usps/parse_city_state_lookup_response.rb +5 -1
  46. data/lib/friendly_shipping/services/usps/parse_package_rate.rb +25 -15
  47. data/lib/friendly_shipping/services/usps/parse_rate_response.rb +5 -2
  48. data/lib/friendly_shipping/services/usps/parse_time_in_transit_response.rb +6 -2
  49. data/lib/friendly_shipping/services/usps/parse_xml_response.rb +15 -5
  50. data/lib/friendly_shipping/services/usps/rate_estimate_package_options.rb +7 -5
  51. data/lib/friendly_shipping/services/usps/shipping_methods.rb +23 -12
  52. data/lib/friendly_shipping/shipping_method.rb +5 -2
  53. data/lib/friendly_shipping/version.rb +1 -1
  54. metadata +75 -40
  55. data/lib/friendly_shipping/services/ups_freight/generate_ups_security_hash.rb +0 -23
  56. data/lib/friendly_shipping/services/ups_freight/parse_json_response.rb +0 -38
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class LabelPickupOptions
7
+ attr_reader :holiday_pickup,
8
+ :inside_pickup,
9
+ :weekend_pickup,
10
+ :lift_gate_required,
11
+ :limited_access_pickup
12
+
13
+ def initialize(
14
+ holiday_pickup: nil,
15
+ inside_pickup: nil,
16
+ weekend_pickup: nil,
17
+ lift_gate_required: nil,
18
+ limited_access_pickup: nil
19
+ )
20
+ @holiday_pickup = holiday_pickup
21
+ @inside_pickup = inside_pickup
22
+ @weekend_pickup = weekend_pickup
23
+ @lift_gate_required = lift_gate_required
24
+ @limited_access_pickup = limited_access_pickup
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/ups_freight/parse_shipment_document'
4
+ require 'friendly_shipping/services/ups_freight/shipment_information'
5
+
6
+ module FriendlyShipping
7
+ module Services
8
+ class UpsFreight
9
+ class ParseFreightLabelResponse
10
+ class << self
11
+ def call(request:, response:)
12
+ json = JSON.parse(response.body)
13
+
14
+ warnings_json = Array.wrap(json.dig("FreightShipResponse", "Response", "Alert"))
15
+ warnings = warnings_json.map do |detailed_warning|
16
+ status = detailed_warning['Code']
17
+ desc = detailed_warning['Description']
18
+ [status, desc].compact.join(": ")
19
+ end.join("\n")
20
+
21
+ shipment_results = json.dig("FreightShipResponse", "ShipmentResults")
22
+
23
+ service_code = shipment_results.dig("Service", "Code")
24
+ shipping_method = SHIPPING_METHODS.detect { |sm| sm.service_code == service_code }
25
+
26
+ total_shipment_charge = shipment_results.dig("TotalShipmentCharge")
27
+ currency = Money::Currency.new(total_shipment_charge['CurrencyCode'])
28
+ amount = total_shipment_charge['MonetaryValue'].to_f
29
+ total_money = Money.new(amount * currency.subunit_to_unit, currency)
30
+
31
+ images_data = Array.wrap(shipment_results.dig("Documents", "Image"))
32
+
33
+ bol_id = shipment_results.dig("BOLID")
34
+ shipment_number = shipment_results.dig("ShipmentNumber")
35
+ pickup_request_number = shipment_results.dig("PickupRequestConfirmationNumber")
36
+
37
+ documents = images_data.map { |image_data| ParseShipmentDocument.call(image_data: image_data) }
38
+
39
+ FriendlyShipping::ApiResult.new(
40
+ ShipmentInformation.new(
41
+ total: total_money,
42
+ bol_id: bol_id,
43
+ number: shipment_number,
44
+ pickup_request_number: pickup_request_number,
45
+ shipping_method: shipping_method,
46
+ warnings: warnings,
47
+ documents: documents
48
+ ),
49
+ original_request: request,
50
+ original_response: response
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'friendly_shipping/rate'
4
4
  require 'friendly_shipping/api_result'
5
- require 'friendly_shipping/services/ups_freight/parse_json_response'
6
5
  require 'friendly_shipping/services/ups_freight/shipping_methods'
7
6
 
8
7
  module FriendlyShipping
@@ -11,40 +10,38 @@ module FriendlyShipping
11
10
  class ParseFreightRateResponse
12
11
  class << self
13
12
  def call(request:, response:)
14
- parsed_json = ParseJSONResponse.call(response.body, 'FreightRateResponse')
13
+ json = JSON.parse(response.body)
15
14
 
16
- parsed_json.fmap do |json|
17
- service_code = json.dig("FreightRateResponse", "Service", "Code")
18
- shipping_method = SHIPPING_METHODS.detect { |sm| sm.service_code == service_code }
19
- total_shipment_charge = json.dig("FreightRateResponse", "TotalShipmentCharge")
20
- currency = Money::Currency.new(total_shipment_charge['CurrencyCode'])
21
- amount = total_shipment_charge['MonetaryValue'].to_f
22
- total_money = Money.new(amount * currency.subunit_to_unit, currency)
23
- data = {
24
- customer_context: json.dig("FreightRateResponse", "TransactionReference", "TransactionIdentifier"),
25
- commodities: Array.wrap(json.dig("FreightRateResponse", "Commodity")),
26
- response_body: json
27
- }
15
+ service_code = json.dig("FreightRateResponse", "Service", "Code")
16
+ shipping_method = SHIPPING_METHODS.detect { |sm| sm.service_code == service_code }
17
+ total_shipment_charge = json.dig("FreightRateResponse", "TotalShipmentCharge")
18
+ currency = Money::Currency.new(total_shipment_charge['CurrencyCode'])
19
+ amount = total_shipment_charge['MonetaryValue'].to_f
20
+ total_money = Money.new(amount * currency.subunit_to_unit, currency)
21
+ data = {
22
+ customer_context: json.dig("FreightRateResponse", "TransactionReference", "TransactionIdentifier"),
23
+ commodities: Array.wrap(json.dig("FreightRateResponse", "Commodity")),
24
+ response_body: json
25
+ }
28
26
 
29
- days_in_transit = json.dig("FreightRateResponse", "TimeInTransit", "DaysInTransit")
30
- if days_in_transit
31
- data[:days_in_transit] = days_in_transit.to_i
32
- end
33
-
34
- FriendlyShipping::ApiResult.new(
35
- [
36
- FriendlyShipping::Rate.new(
37
- amounts: {
38
- total: total_money
39
- },
40
- shipping_method: shipping_method,
41
- data: data
42
- )
43
- ],
44
- original_request: request,
45
- original_response: response
46
- )
27
+ days_in_transit = json.dig("FreightRateResponse", "TimeInTransit", "DaysInTransit")
28
+ if days_in_transit
29
+ data[:days_in_transit] = days_in_transit.to_i
47
30
  end
31
+
32
+ FriendlyShipping::ApiResult.new(
33
+ [
34
+ FriendlyShipping::Rate.new(
35
+ amounts: {
36
+ total: total_money
37
+ },
38
+ shipping_method: shipping_method,
39
+ data: data
40
+ )
41
+ ],
42
+ original_request: request,
43
+ original_response: response
44
+ )
48
45
  end
49
46
  end
50
47
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/ups_freight/shipment_document'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ class UpsFreight
8
+ class ParseShipmentDocument
9
+ REVERSE_DOCUMENT_TYPES = LabelDocumentOptions::DOCUMENT_TYPES.map(&:reverse_each).map(&:to_a).to_h
10
+
11
+ def self.call(image_data:)
12
+ format_code = image_data.dig("Type", "Code")
13
+ graphic_image_b64 = image_data.dig("GraphicImage")
14
+
15
+ ShipmentDocument.new(
16
+ format: image_data.dig("Format", "Code").downcase.to_sym,
17
+ binary: Base64.decode64(graphic_image_b64),
18
+ document_type: REVERSE_DOCUMENT_TYPES.fetch(format_code)
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class PickupRequestOptions
7
+ attr_reader :pickup_time_window,
8
+ :requester,
9
+ :third_party_requester,
10
+ :requester_email,
11
+ :comments
12
+
13
+ def initialize(
14
+ pickup_time_window:,
15
+ requester:,
16
+ requester_email:,
17
+ comments: nil,
18
+ third_party_requester: false
19
+ )
20
+ @pickup_time_window = pickup_time_window
21
+ @requester = requester
22
+ @third_party_requester = third_party_requester
23
+ @requester_email = requester_email
24
+ @comments = comments
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -31,8 +31,7 @@ module FriendlyShipping
31
31
  :billing_code,
32
32
  :customer_context,
33
33
  :shipping_method,
34
- :pickup_date,
35
- :pickup_comments,
34
+ :pickup_request_options,
36
35
  :commodity_information_generator
37
36
 
38
37
  def initialize(
@@ -41,8 +40,7 @@ module FriendlyShipping
41
40
  shipping_method:,
42
41
  billing: :prepaid,
43
42
  customer_context: nil,
44
- pickup_date: nil,
45
- pickup_comments: nil,
43
+ pickup_request_options: nil,
46
44
  commodity_information_generator: GenerateCommodityInformation,
47
45
  **kwargs
48
46
  )
@@ -51,8 +49,7 @@ module FriendlyShipping
51
49
  @shipping_method = shipping_method
52
50
  @billing_code = BILLING_CODES.fetch(billing)
53
51
  @customer_context = customer_context
54
- @pickup_date = pickup_date
55
- @pickup_comments = pickup_comments
52
+ @pickup_request_options = pickup_request_options
56
53
  @commodity_information_generator = commodity_information_generator
57
54
  super(**kwargs.merge(package_options_class: RatesPackageOptions))
58
55
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class RestfulApiErrorHandler
7
+ extend Dry::Monads::Result::Mixin
8
+
9
+ def self.call(error, original_request: nil, original_response: nil)
10
+ parsed_json = JSON.parse(error.response.body)
11
+ errors = parsed_json.dig('response', 'errors')
12
+
13
+ failure_string = errors.map do |err|
14
+ status = err['code']
15
+ desc = err['message']
16
+ [status, desc].compact.join(": ").presence || 'UPS could not process the request.'
17
+ end.join("\n")
18
+
19
+ Failure(
20
+ ApiFailure.new(
21
+ failure_string,
22
+ original_request: original_request,
23
+ original_response: original_response
24
+ )
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class ShipmentDocument
7
+ attr_reader :format, :document_type, :binary
8
+
9
+ def initialize(
10
+ format:,
11
+ document_type:,
12
+ binary:
13
+ )
14
+ @format = format
15
+ @document_type = document_type
16
+ @binary = binary
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class ShipmentInformation
7
+ attr_reader :documents,
8
+ :number,
9
+ :pickup_request_number,
10
+ :total,
11
+ :bol_id,
12
+ :shipping_method,
13
+ :warnings
14
+
15
+ def initialize(
16
+ total:,
17
+ bol_id:,
18
+ number:,
19
+ pickup_request_number: nil,
20
+ documents: [],
21
+ shipping_method: nil,
22
+ warnings: nil
23
+ )
24
+ @total = total
25
+ @bol_id = bol_id
26
+ @number = number
27
+ @pickup_request_number = pickup_request_number
28
+ @documents = documents
29
+ @shipping_method = shipping_method
30
+ @warnings = warnings
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -12,7 +12,11 @@ module FriendlyShipping
12
12
  # @return [Result<FriendlyShipping::AddressValidationResult>]
13
13
  def call(request:, response:)
14
14
  # Filter out error responses and directly return a failure
15
- parsing_result = ParseXMLResponse.call(response.body, 'AddressValidateResponse')
15
+ parsing_result = ParseXMLResponse.call(
16
+ request: request,
17
+ response: response,
18
+ expected_root_tag: 'AddressValidateResponse'
19
+ )
16
20
  parsing_result.fmap do |xml|
17
21
  address = xml.root.at('Address')
18
22
  suggestions = [
@@ -12,7 +12,11 @@ module FriendlyShipping
12
12
  # @return [Result<FriendlyShipping::AddressValidationResult>]
13
13
  def call(request:, response:)
14
14
  # Filter out error responses and directly return a failure
15
- parsing_result = ParseXMLResponse.call(response.body, 'CityStateLookupResponse')
15
+ parsing_result = ParseXMLResponse.call(
16
+ request: request,
17
+ response: response,
18
+ expected_root_tag: 'CityStateLookupResponse'
19
+ )
16
20
  parsing_result.fmap do |xml|
17
21
  address = xml.root.at('ZipCode')
18
22
  suggestions = [
@@ -54,6 +54,7 @@ module FriendlyShipping
54
54
  SERVICE_NAME_TAG = 'MailService'
55
55
  RATE_TAG = 'Rate'
56
56
  COMMERCIAL_RATE_TAG = 'CommercialRate'
57
+ COMMERCIAL_PLUS_RATE_TAG = 'CommercialPlusRate'
57
58
  CURRENCY = Money::Currency.new('USD').freeze
58
59
 
59
60
  class << self
@@ -82,27 +83,36 @@ module FriendlyShipping
82
83
 
83
84
  # Some USPS services only offer commercial pricing. Unfortunately, USPS then returns a retail rate of 0.
84
85
  # In these cases, return the commercial rate instead of the normal rate.
86
+ #
85
87
  # Some rates are available in both commercial and retail pricing - if we want the commercial pricing here,
86
88
  # we need to specify the commercial_pricing property on the `Physical::Package`.
87
- rate_value = if (package_options.commercial_pricing || rate_node.at(RATE_TAG).text.to_d.zero?) && rate_node.at(COMMERCIAL_RATE_TAG)
88
- rate_node.at(COMMERCIAL_RATE_TAG).text.to_d
89
- else
90
- rate_node.at(RATE_TAG).text.to_d
91
- end
89
+ #
90
+ commercial_rate_requested_or_rate_is_zero = package_options.commercial_pricing || rate_node.at(RATE_TAG).text.to_d.zero?
91
+ commercial_rate_available = rate_node.at(COMMERCIAL_RATE_TAG) || rate_node.at(COMMERCIAL_PLUS_RATE_TAG)
92
+
93
+ rate_value =
94
+ if commercial_rate_requested_or_rate_is_zero && commercial_rate_available
95
+ rate_node.at(COMMERCIAL_RATE_TAG)&.text&.to_d || rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d
96
+ else
97
+ rate_node.at(RATE_TAG).text.to_d
98
+ end
92
99
 
93
100
  # The rate expressed as a RubyMoney objext
94
101
  rate = Money.new(rate_value * CURRENCY.subunit_to_unit, CURRENCY)
95
102
 
96
- # Which shipping method does this rate belong to? This is trickier than it sounds, because we match
97
- # strings here, and we have a `Priority Mail` and `Priority Mail Express` shipping method.
98
- # If we have multiple matches, we take the longest matching shipping method name so `Express` rates
99
- # do not accidentally get marked as `Priority` only.
100
- possible_shipping_methods = SHIPPING_METHODS.select do |sm|
101
- service_name.tr('-', ' ').upcase.starts_with?(sm.service_code)
102
- end.sort_by do |shipping_method|
103
- shipping_method.name.length
104
- end
105
- shipping_method = possible_shipping_methods.last
103
+ # Which shipping method does this rate belong to? We first try to match a rate to a shipping method
104
+ # by class ID (the CLASSID attribute in the USPS API rate response). Not every shipping method
105
+ # has a class ID defined, and a shipping method can have multiple class IDs (for example, Priority
106
+ # Express has different class IDs for standard, hold for pickup, and Sunday/Holiday delivery).
107
+ #
108
+ # If we don't find a match for class ID, we next try to match a rate to a shipping method using the
109
+ # shipping method's service code. The USPS API rate response includes a name for each rate in the
110
+ # MailService element. We match to see if the name starts with the given value. For example:
111
+ # `Priority Mail Express 2-day™`
112
+ #
113
+ shipping_method =
114
+ SHIPPING_METHODS.detect { |sm| sm.data[:class_ids]&.include?(service_code) } ||
115
+ SHIPPING_METHODS.detect { |sm| service_name.tr('-', ' ').upcase.starts_with?(sm.service_code) }
106
116
 
107
117
  # We find out the box name using a bit of Regex magic using named captures. See the `BOX_REGEX`
108
118
  # constant above.
@@ -20,8 +20,11 @@ module FriendlyShipping
20
20
  # @return [Result<ApiResult<Array<FriendlyShipping::Rate>>>] When successfully parsing, an array of rates in a Success Monad.
21
21
  def call(request:, response:, shipment:, options:)
22
22
  # Filter out error responses and directly return a failure
23
- parsing_result = ParseXMLResponse.call(response.body, 'RateV4Response')
24
- rates = []
23
+ parsing_result = ParseXMLResponse.call(
24
+ request: request,
25
+ response: response,
26
+ expected_root_tag: 'RateV4Response'
27
+ )
25
28
  parsing_result.fmap do |xml|
26
29
  # Get all the possible rates for each package
27
30
  rates_by_package = rates_from_response_node(xml, shipment, options)
@@ -15,7 +15,11 @@ module FriendlyShipping
15
15
  # @return [Result<ApiResult<Array<FriendlyShipping::Timing>>>] When successfully parsing, an array of timings in a Success Monad.
16
16
  def call(request:, response:)
17
17
  # Filter out error responses and directly return a failure
18
- parsing_result = ParseXMLResponse.call(response.body, 'SDCGetLocationsResponse')
18
+ parsing_result = ParseXMLResponse.call(
19
+ request: request,
20
+ response: response,
21
+ expected_root_tag: 'SDCGetLocationsResponse'
22
+ )
19
23
  parsing_result.fmap do |xml|
20
24
  expedited_commitments = xml.xpath('//Expedited')
21
25
  expedited_timings = parse_expedited_commitment_nodes(expedited_commitments)
@@ -111,7 +115,7 @@ module FriendlyShipping
111
115
  # This will likely be somewhat more work in the future.
112
116
  MAIL_CLASSES = {
113
117
  '1' => 'Priority Mail Express',
114
- '2' => 'Priority',
118
+ '2' => 'Priority Mail',
115
119
  '3' => 'First-Class',
116
120
  '6' => 'Package Services'
117
121
  }.freeze