friendly_shipping 0.3.4 → 0.4.0

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.env.template +1 -0
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +13 -4
  5. data/Gemfile +1 -0
  6. data/README.md +21 -2
  7. data/friendly_shipping.gemspec +1 -1
  8. data/lib/friendly_shipping/api_failure.rb +3 -0
  9. data/lib/friendly_shipping/api_result.rb +3 -0
  10. data/lib/friendly_shipping/carrier.rb +6 -0
  11. data/lib/friendly_shipping/http_client.rb +1 -0
  12. data/lib/friendly_shipping/item_options.rb +11 -0
  13. data/lib/friendly_shipping/label.rb +17 -9
  14. data/lib/friendly_shipping/package_options.rb +28 -0
  15. data/lib/friendly_shipping/rate.rb +9 -8
  16. data/lib/friendly_shipping/request.rb +4 -0
  17. data/lib/friendly_shipping/response.rb +3 -0
  18. data/lib/friendly_shipping/services/ship_engine.rb +10 -11
  19. data/lib/friendly_shipping/services/ship_engine/label_options.rb +34 -0
  20. data/lib/friendly_shipping/services/ship_engine/label_package_options.rb +28 -0
  21. data/lib/friendly_shipping/services/ship_engine/parse_label_response.rb +6 -1
  22. data/lib/friendly_shipping/services/ship_engine/parse_rate_estimate_response.rb +7 -7
  23. data/lib/friendly_shipping/services/ship_engine/rate_estimates_options.rb +25 -0
  24. data/lib/friendly_shipping/services/ship_engine/serialize_label_shipment.rb +15 -14
  25. data/lib/friendly_shipping/services/ship_engine/serialize_rate_estimate_request.rb +2 -2
  26. data/lib/friendly_shipping/services/ups.rb +47 -2
  27. data/lib/friendly_shipping/services/ups/label_billing_options.rb +41 -0
  28. data/lib/friendly_shipping/services/ups/label_item_options.rb +74 -0
  29. data/lib/friendly_shipping/services/ups/label_options.rb +165 -0
  30. data/lib/friendly_shipping/services/ups/label_package_options.rb +43 -0
  31. data/lib/friendly_shipping/services/ups/parse_money_element.rb +128 -0
  32. data/lib/friendly_shipping/services/ups/parse_rate_response.rb +8 -7
  33. data/lib/friendly_shipping/services/ups/parse_shipment_accept_response.rb +75 -0
  34. data/lib/friendly_shipping/services/ups/parse_shipment_confirm_response.rb +22 -0
  35. data/lib/friendly_shipping/services/ups/parse_xml_response.rb +2 -1
  36. data/lib/friendly_shipping/services/ups/serialize_address_snippet.rb +11 -6
  37. data/lib/friendly_shipping/services/ups/serialize_package_node.rb +21 -6
  38. data/lib/friendly_shipping/services/ups/serialize_shipment_accept_request.rb +27 -0
  39. data/lib/friendly_shipping/services/ups/serialize_shipment_address_snippet.rb +21 -0
  40. data/lib/friendly_shipping/services/ups/serialize_shipment_confirm_request.rb +282 -0
  41. data/lib/friendly_shipping/services/ups_freight.rb +76 -0
  42. data/lib/friendly_shipping/services/ups_freight/generate_commodity_information.rb +33 -0
  43. data/lib/friendly_shipping/services/ups_freight/generate_freight_rate_request_hash.rb +72 -0
  44. data/lib/friendly_shipping/services/ups_freight/generate_location_hash.rb +39 -0
  45. data/lib/friendly_shipping/services/ups_freight/generate_ups_security_hash.rb +23 -0
  46. data/lib/friendly_shipping/services/ups_freight/parse_freight_rate_response.rb +53 -0
  47. data/lib/friendly_shipping/services/ups_freight/parse_json_response.rb +38 -0
  48. data/lib/friendly_shipping/services/ups_freight/rates_item_options.rb +72 -0
  49. data/lib/friendly_shipping/services/ups_freight/rates_options.rb +54 -0
  50. data/lib/friendly_shipping/services/ups_freight/rates_package_options.rb +38 -0
  51. data/lib/friendly_shipping/services/ups_freight/shipping_methods.rb +25 -0
  52. data/lib/friendly_shipping/services/usps.rb +1 -1
  53. data/lib/friendly_shipping/services/usps/parse_xml_response.rb +1 -1
  54. data/lib/friendly_shipping/services/usps/serialize_rate_request.rb +0 -4
  55. data/lib/friendly_shipping/shipment_options.rb +23 -0
  56. data/lib/friendly_shipping/shipping_method.rb +7 -0
  57. data/lib/friendly_shipping/version.rb +1 -1
  58. metadata +33 -6
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads/result'
4
+ require 'friendly_shipping/http_client'
5
+ require 'friendly_shipping/services/ups_freight/shipping_methods'
6
+ require 'friendly_shipping/services/ups_freight/rates_options'
7
+ require 'friendly_shipping/services/ups_freight/rates_package_options'
8
+ require 'friendly_shipping/services/ups_freight/rates_item_options'
9
+ require 'friendly_shipping/services/ups_freight/parse_freight_rate_response'
10
+ require 'friendly_shipping/services/ups_freight/generate_freight_rate_request_hash'
11
+ require 'friendly_shipping/services/ups_freight/generate_ups_security_hash'
12
+
13
+ module FriendlyShipping
14
+ module Services
15
+ class UpsFreight
16
+ include Dry::Monads::Result::Mixin
17
+
18
+ attr_reader :test, :key, :login, :password, :client
19
+
20
+ CARRIER = FriendlyShipping::Carrier.new(
21
+ id: 'ups_freight',
22
+ name: 'United Parcel Service LTL',
23
+ code: 'ups-freight',
24
+ shipping_methods: SHIPPING_METHODS
25
+ )
26
+
27
+ TEST_URL = 'https://wwwcie.ups.com'
28
+ LIVE_URL = 'https://onlinetools.ups.com'
29
+
30
+ RESOURCES = {
31
+ rates: '/rest/FreightRate'
32
+ }.freeze
33
+
34
+ def initialize(key:, login:, password:, test: true, client: HttpClient.new)
35
+ @key = key
36
+ @login = login
37
+ @password = password
38
+ @test = test
39
+ @client = client
40
+ end
41
+
42
+ def carriers
43
+ Success([CARRIER])
44
+ end
45
+
46
+ # Get rates for a shipment
47
+ # @param [Physical::Shipment] location The shipment we want to get rates for
48
+ # @param [FriendlyShipping::Services::UpsFreight::RatesOptions] options Options for obtaining rates for this shipment.
49
+ # @return [Result<ApiResult<Array<Rate>>>] The rates returned from UPS encoded in a
50
+ # `FriendlyShipping::ApiResult` object.
51
+ def rate_estimates(shipment, options:, debug: false)
52
+ freight_rate_request_hash = GenerateFreightRateRequestHash.call(shipment: shipment, options: options)
53
+ url = base_url + RESOURCES[:rates]
54
+ request = FriendlyShipping::Request.new(
55
+ url: url,
56
+ body: authentication_hash.merge(freight_rate_request_hash).to_json,
57
+ debug: debug
58
+ )
59
+
60
+ client.post(request).bind do |response|
61
+ ParseFreightRateResponse.call(response: response, request: request)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def authentication_hash
68
+ GenerateUpsSecurityHash.call(key: key, login: login, password: password)
69
+ end
70
+
71
+ def base_url
72
+ test ? TEST_URL : LIVE_URL
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class GenerateCommodityInformation
7
+ def self.call(shipment:, options:)
8
+ shipment.packages.flat_map do |package|
9
+ package_options = options.options_for_package(package)
10
+ package.items.map do |item|
11
+ item_options = package_options.options_for_item(item)
12
+ {
13
+ # This is a required field
14
+ Description: item.description || 'Commodities',
15
+ Weight: {
16
+ UnitOfMeasurement: {
17
+ Code: 'LBS' # Only Pounds are supported
18
+ },
19
+ Value: item.weight.convert_to(:pounds).value.to_f.round(2).to_s
20
+ },
21
+ NumberOfPieces: '1', # We won't support this yet.
22
+ PackagingType: {
23
+ Code: item_options.packaging_code
24
+ },
25
+ FreightClass: item_options.freight_class
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/ups_freight/generate_location_hash'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ class UpsFreight
8
+ class GenerateFreightRateRequestHash
9
+ class << self
10
+ def call(shipment:, options:)
11
+ {
12
+ FreightRateRequest: {
13
+ Request: request_options(options.customer_context),
14
+ ShipperNumber: options.shipper_number,
15
+ ShipFrom: GenerateLocationHash.call(location: shipment.origin),
16
+ ShipTo: GenerateLocationHash.call(location: shipment.destination),
17
+ PaymentInformation: payment_information(options),
18
+ Service: {
19
+ Code: options.shipping_method.service_code
20
+ },
21
+ Commodity: options.commodity_information_generator.call(shipment: shipment, options: options),
22
+ TimeInTransitIndicator: true
23
+ }.compact.merge(handling_units(shipment, options).reduce(&:merge).to_h)
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def handling_units(shipment, options)
30
+ all_package_options = shipment.packages.map { |package| options.options_for_package(package) }
31
+ all_package_options.group_by(&:handling_unit_code).map do |_handling_unit_code, options_group|
32
+ [options_group.first, options_group.length]
33
+ end.map { |package_options, quantity| handling_unit_hash(package_options, quantity) }
34
+ end
35
+
36
+ def handling_unit_hash(package_options, quantity)
37
+ {
38
+ package_options.handling_unit_tag => {
39
+ Quantity: quantity.to_s,
40
+ Type: {
41
+ Code: package_options.handling_unit_code,
42
+ Description: package_options.handling_unit_description
43
+ }
44
+ }
45
+ }
46
+ end
47
+
48
+ def request_options(customer_context)
49
+ return {} unless customer_context
50
+
51
+ {
52
+ TransactionReference: {
53
+ CustomerContext: customer_context
54
+ }
55
+ }
56
+ end
57
+
58
+ def payment_information(options)
59
+ payer_address = GenerateLocationHash.call(location: options.billing_address).
60
+ merge(ShipperNumber: options.shipper_number)
61
+ {
62
+ Payer: payer_address,
63
+ ShipmentBillingOption: {
64
+ Code: options.billing_code
65
+ }
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class GenerateLocationHash
7
+ class << self
8
+ def call(location:)
9
+ # We ship freight here, which will mostly be used for businesses.
10
+ # If a personal name is given, treat is as the contact person ("AttentionName")
11
+ {
12
+ Name: location.company_name,
13
+ Address: {
14
+ AddressLine: address_line(location),
15
+ City: location.city,
16
+ StateProvinceCode: location.region.code,
17
+ PostalCode: location.zip,
18
+ CountryCode: location.country.code
19
+ },
20
+ AttentionName: location.name
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def address_line(location)
27
+ [
28
+ location.address1,
29
+ location.address2,
30
+ location.address3
31
+ ].compact.
32
+ reject(&:empty?).
33
+ join(", ")
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class GenerateUpsSecurityHash
7
+ def self.call(login:, password:, key:)
8
+ {
9
+ UPSSecurity: {
10
+ UsernameToken: {
11
+ Username: login,
12
+ Password: password
13
+ },
14
+ ServiceAccessToken: {
15
+ AccessLicenseNumber: key
16
+ }
17
+ }
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/rate'
4
+ require 'friendly_shipping/api_result'
5
+ require 'friendly_shipping/services/ups_freight/parse_json_response'
6
+ require 'friendly_shipping/services/ups_freight/shipping_methods'
7
+
8
+ module FriendlyShipping
9
+ module Services
10
+ class UpsFreight
11
+ class ParseFreightRateResponse
12
+ class << self
13
+ def call(request:, response:)
14
+ parsed_json = ParseJSONResponse.call(response.body, 'FreightRateResponse')
15
+
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
+ }
28
+
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
+ )
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ class ParseJSONResponse
7
+ extend Dry::Monads::Result::Mixin
8
+
9
+ class << self
10
+ def call(response_body, expected_root_tag)
11
+ json = JSON.parse(response_body)
12
+
13
+ if request_successful?(json, expected_root_tag)
14
+ Success(json)
15
+ else
16
+ Failure(error_message(json))
17
+ end
18
+ rescue JSON::ParserError => e
19
+ Failure(e)
20
+ end
21
+
22
+ private
23
+
24
+ def request_successful?(json, expected_root_tag)
25
+ json[expected_root_tag].present?
26
+ end
27
+
28
+ def error_message(json)
29
+ detailed_error = json.dig('Fault', 'detail', 'Errors', 'ErrorDetail', 'PrimaryErrorCode')
30
+ status = detailed_error['Code']
31
+ desc = detailed_error['Description']
32
+ [status, desc].compact.join(": ").presence || 'UPS could not process the request.'
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class UpsFreight
6
+ # Options for Items in a UPS Freight shipment
7
+ #
8
+ # @attribute [Symbol] packaging How the item is packaged, one of the keys of `PACKAGING_TYPES`
9
+ # @attribute [String] freight_class The freight class of this item, for example '55' or '92.5'
10
+ # @attribute [String] nmfc_code The national motor freight corporation code for this item. Something like '13050 sub 4'
11
+ class RatesItemOptions < FriendlyShipping::ItemOptions
12
+ PACKAGING_TYPES = {
13
+ bag: { code: "BAG", description: "Bag" },
14
+ bale: { code: "BAL", description: "Bale" },
15
+ barrel: { code: "BAR", description: "Barrel" },
16
+ bundle: { code: "BDL", description: "Bundle" },
17
+ bin: { code: "BIN", description: "Bin" },
18
+ box: { code: "BOX", description: "Box" },
19
+ basket: { code: "BSK", description: "Basket" },
20
+ bunch: { code: "BUN", description: "Bunch" },
21
+ cabinet: { code: "CAB", description: "Cabinet" },
22
+ can: { code: "CAN", description: "Can" },
23
+ carrier: { code: "CAR", description: "Carrier" },
24
+ case: { code: "CAS", description: "Case" },
25
+ carboy: { code: "CBY", description: "Carboy" },
26
+ container: { code: "CON", description: "Container" },
27
+ crate: { code: "CRT", description: "Crate" },
28
+ cask: { code: "CSK", description: "Cask" },
29
+ carton: { code: "CTN", description: "Carton" },
30
+ cylinder: { code: "CYL", description: "Cylinder" },
31
+ drum: { code: "DRM", description: "Drum" },
32
+ loose: { code: "LOO", description: "Loose" },
33
+ other: { code: "OTH", description: "Other" },
34
+ pail: { code: "PAL", description: "Pail" },
35
+ pieces: { code: "PCS", description: "Pieces" },
36
+ package: { code: "PKG", description: "Package" },
37
+ pipe_line: { code: "PLN", description: "Pipe Line" },
38
+ pallet: { code: "PLT", description: "Pallet" },
39
+ rack: { code: "RCK", description: "Rack" },
40
+ reel: { code: "REL", description: "Reel" },
41
+ roll: { code: "ROL", description: "Roll" },
42
+ skid: { code: "SKD", description: "Skid" },
43
+ spool: { code: "SPL", description: "Spool" },
44
+ tube: { code: "TBE", description: "Tube" },
45
+ tank: { code: "TNK", description: "Tank" },
46
+ totes: { code: "TOT", description: "Totes" },
47
+ unit: { code: "UNT", description: "Unit" },
48
+ van_pack: { code: "VPK", description: "Van Pack" },
49
+ wrapped: { code: "WRP", description: "Wrapped" }
50
+ }.freeze
51
+
52
+ attr_reader :packaging_code,
53
+ :packaging_description,
54
+ :freight_class,
55
+ :nmfc_code
56
+
57
+ def initialize(
58
+ packaging: :carton,
59
+ freight_class: nil,
60
+ nmfc_code: nil,
61
+ **kwargs
62
+ )
63
+ @packaging_code = PACKAGING_TYPES.fetch(packaging).fetch(:code)
64
+ @packaging_description = PACKAGING_TYPES.fetch(packaging).fetch(:description)
65
+ @freight_class = freight_class
66
+ @nmfc_code = nmfc_code
67
+ super kwargs
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/ups_freight/rates_package_options'
4
+ require 'friendly_shipping/services/ups_freight/generate_commodity_information'
5
+
6
+ module FriendlyShipping
7
+ module Services
8
+ class UpsFreight
9
+ # Options for generating UPS Freight rates for a shipment
10
+ #
11
+ # @attribute [Physical::Location] billing_address The billing address
12
+ # @attribute [String] shipper_number The shipper number associated with the shipper
13
+ # @attribute [String] customer_context A reference to match this request with an order or shipment
14
+ # @attribute [FriendlyShipping::ShippingMethod] shipping_method The shipping method to use
15
+ # @attribute [Callable] commodity_information_generator A callable that takes a shipment
16
+ # and an options object to create an Array of commodity fields as per the UPS docs.
17
+ # @attribute [Symbol] billing One of the keys in the `BILLING_CODES` constant. How the shipment
18
+ # would be billed.
19
+ # @attribute [RatesPackageOptions] package_options Options for each of the packages/pallets in this shipment
20
+ class RatesOptions < FriendlyShipping::ShipmentOptions
21
+ BILLING_CODES = {
22
+ prepaid: '10',
23
+ third_party: '30',
24
+ freight_collect: '40'
25
+ }.freeze
26
+
27
+ attr_reader :shipper_number,
28
+ :billing_address,
29
+ :billing_code,
30
+ :customer_context,
31
+ :shipping_method,
32
+ :commodity_information_generator
33
+
34
+ def initialize(
35
+ shipper_number:,
36
+ billing_address:,
37
+ shipping_method:,
38
+ billing: :prepaid,
39
+ customer_context: nil,
40
+ commodity_information_generator: GenerateCommodityInformation,
41
+ **kwargs
42
+ )
43
+ @shipper_number = shipper_number
44
+ @billing_address = billing_address
45
+ @shipping_method = shipping_method
46
+ @billing_code = BILLING_CODES.fetch(billing)
47
+ @customer_context = customer_context
48
+ @commodity_information_generator = commodity_information_generator
49
+ super kwargs.merge(package_options_class: RatesPackageOptions)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end