friendly_shipping 0.3.4 → 0.4.0

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