friendly_shipping 0.5.2 → 0.6.3

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