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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/ups/label_item_options'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ class Ups
8
+ # Package properties relevant for generating a UPS shipping label
9
+ #
10
+ # @option reference_numbers [Hash] a Hash where keys are _reference number codes_ and
11
+ # values are _reference number values_. Example: `{ reference_numbers: { xn: 'my_reference_1 }`
12
+ # @option delivery_confirmation [Symbol] Can be set to any key from PACKAGE_DELIVERY_CONFIRMATION_CODES.
13
+ # Only possible for domestic shipments or shipments between the US and Puerto Rico.
14
+ class LabelPackageOptions < FriendlyShipping::PackageOptions
15
+ PACKAGE_DELIVERY_CONFIRMATION_CODES = {
16
+ delivery_confirmation: 1,
17
+ delivery_confirmation_signature_required: 2,
18
+ delivery_confirmation_adult_signature_required: 3
19
+ }.freeze
20
+
21
+ attr_reader :reference_numbers
22
+
23
+ def initialize(
24
+ reference_numbers: {},
25
+ delivery_confirmation: nil,
26
+ **kwargs
27
+ )
28
+ @reference_numbers = reference_numbers
29
+ @delivery_confirmation = delivery_confirmation
30
+ super kwargs.merge(item_options_class: LabelItemOptions)
31
+ end
32
+
33
+ def delivery_confirmation_code
34
+ PACKAGE_DELIVERY_CONFIRMATION_CODES[delivery_confirmation]
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :delivery_confirmation
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Ups
6
+ class ParseMoneyElement
7
+ def self.call(element)
8
+ return unless element
9
+
10
+ monetary_value = element.at('MonetaryValue').text.to_d
11
+ return if monetary_value.zero?
12
+
13
+ currency_code = element.at('CurrencyCode').text
14
+ currency = Money::Currency.new(currency_code)
15
+ amount = Money.new(monetary_value * currency.subunit_to_unit, currency)
16
+
17
+ surcharge_code = element.at('Code')&.text
18
+ label = surcharge_code ? UPS_SURCHARGE_CODES[surcharge_code] : element.name
19
+
20
+ [label, amount]
21
+ end
22
+
23
+ UPS_SURCHARGE_CODES = {
24
+ "100" => "ADDITIONAL HANDLING",
25
+ "110" => "COD",
26
+ "120" => "DELIVERY CONFIRMATION",
27
+ "121" => "SHIP DELIVERY CONFIRMATION",
28
+ "153" => "PKG EMAIL SHIP NOTIFICATION",
29
+ "154" => "PKG EMAIL RETURN NOTIFICATION",
30
+ "155" => "PKG EMAIL INBOUND RETURN NOTIFICATION",
31
+ "156" => "PKG EMAIL QUANTUM VIEW SHIP NOTIFICATION",
32
+ "157" => "PKG EMAIL QUANTUM VIEW EXCEPTION NOTIFICATION",
33
+ "158" => "PKG EMAIL QUANTUM VIEW DELIVERY NOTIFICATION",
34
+ "165" => "PKG FAX INBOUND RETURN NOTIFICATION",
35
+ "166" => "PKG FAX QUANTUM VIEW SHIP NOTIFICATION",
36
+ "171" => "SHIP EMAIL ERL NOTIFICATION",
37
+ "173" => "SHIP EMAIL SHIP NOTIFICATION",
38
+ "174" => "SHIP EMAIL RETURN NOTIFICATION",
39
+ "175" => "SHIP EMAIL INBOUND RETURN NOTIFICATION",
40
+ "176" => "SHIP EMAIL QUANTUM VIEW SHIP NOTIFICATION",
41
+ "177" => "SHIP EMAIL QUANTUM VIEW EXCEPTION NOTIFICATION",
42
+ "178" => "SHIP EMAIL QUANTUM VIEW DELIVERY NOTIFICATION",
43
+ "179" => "SHIP EMAIL QUANTUM VIEW NOTIFY",
44
+ "187" => "SHIP UPS ACCESS POINT NOTIFICATION",
45
+ "188" => "SHIP EEI FILING NOTIFICATION",
46
+ "189" => "SHIP UAP SHIPPER NOTIFICATION",
47
+ "190" => "EXTENDED AREA",
48
+ "200" => "DRY ICE",
49
+ "220" => "HOLD FOR PICKUP",
50
+ "240" => "ORIGIN CERTIFICATE",
51
+ "250" => "PRINT RETURN LABEL",
52
+ "258" => "EXPORT LICENSE VERIFICATION",
53
+ "260" => "PRINT N MAIL",
54
+ "270" => "RESIDENTIAL ADDRESS",
55
+ "280" => "RETURN SERVICE 1ATTEMPT",
56
+ "290" => "RETURN SERVICE 3ATTEMPT",
57
+ "300" => "SATURDAY DELIVERY",
58
+ "310" => "SATURDAY PICKUP",
59
+ "330" => "PKG VERBAL CONFIRMATION",
60
+ "350" => "ELECTRONIC RETURN LABEL",
61
+ "372" => "QUANTUM VIEW NOTIFY DELIVERY",
62
+ "374" => "UPS PREPARED SED FORM",
63
+ "375" => "FUEL SURCHARGE",
64
+ "376" => "DELIVERY AREA",
65
+ "377" => "LARGE PACKAGE",
66
+ "378" => "SHIPPER PAYS DUTY TAX",
67
+ "379" => "SHIPPER PAYS DUTY TAX UNPAID",
68
+ "400" => "INSURANCE",
69
+ "401" => "SHIP ADDITIONAL HANDLING",
70
+ "402" => "SHIPPER RELEASE",
71
+ "403" => "CHECK TO SHIPPER",
72
+ "404" => "UPS PROACTIVE RESPONSE",
73
+ "405" => "GERMAN PICKUP",
74
+ "406" => "GERMAN ROAD TAX",
75
+ "407" => "EXTENDED AREA PICKUP",
76
+ "410" => "RETURN OF DOCUMENT",
77
+ "430" => "PEAK SEASON",
78
+ "431" => "PEAK SEASON SURCHARGE - LARGE PACK",
79
+ "432" => "PEAK SEASON SURCHARGE - ADDITIONAL HANDLING",
80
+ "440" => "SHIP LARGE PACKAGE",
81
+ "441" => "CARBON NEUTRAL",
82
+ "442" => "PKG QV IN TRANSIT NOTIFICATION",
83
+ "443" => "SHIP QV IN TRANSIT NOTIFICATION",
84
+ "444" => "IMPORT CONTROL",
85
+ "445" => "COMMERCIAL INVOICE REMOVAL",
86
+ "446" => "IMPORT CONTROL ELECTRONIC LABEL",
87
+ "447" => "IMPORT CONTROL PRINT LABEL",
88
+ "448" => "IMPORT CONTROL PRINT AND MAIL LABEL",
89
+ "449" => "IMPORT CONTROL ONE PICK UP ATTEMPT LABEL",
90
+ "450" => "IMPORT CONTROL THREE PICK UP ATTEMPT LABEL",
91
+ "452" => "REFRIGERATION",
92
+ "454" => "PAC 1A BOX1",
93
+ "455" => "PAC 3A BOX1",
94
+ "456" => "PAC 1A BOX2",
95
+ "457" => "PAC 3A BOX2",
96
+ "458" => "PAC 1A BOX3",
97
+ "459" => "PAC 3A BOX3",
98
+ "460" => "PAC 1A BOX4",
99
+ "461" => "PAC 3A BOX4",
100
+ "462" => "PAC 1A BOX5",
101
+ "463" => "PAC 3A BOX5",
102
+ "464" => "EXCHANGE PRINT RETURN LABEL",
103
+ "465" => "EXCHANGE FORWARD",
104
+ "466" => "SHIP PREALERT NOTIFICATION",
105
+ "470" => "COMMITTED DELIVERY WINDOW",
106
+ "480" => "SECURITY SURCHARGE",
107
+ "492" => "CUSTOMER TRANSACTION FEE",
108
+ "500" => "SHIPMENT COD",
109
+ "510" => "LIFT GATE FOR PICKUP",
110
+ "511" => "LIFT GATE FOR DELIVERY",
111
+ "512" => "DROP OFF AT UPS FACILITY",
112
+ "515" => "UPS PREMIUM CARE",
113
+ "520" => "OVERSIZE PALLET",
114
+ "530" => "FREIGHT DELIVERY SURCHARGE",
115
+ "531" => "FREIGHT PICKUP SURCHARGE",
116
+ "540" => "DIRECT TO RETAIL",
117
+ "541" => "DIRECT DELIVERY ONLY",
118
+ "542" => "DELIVER TO ADDRESSEE ONLY",
119
+ "543" => "DIRECT TO RETAIL COD",
120
+ "544" => "RETAIL ACCESS POINT545 SHIPPING TICKET NOTIFICATION",
121
+ "546" => "ELECTRONIC PACKAGE RELEASE AUTHENTICATION",
122
+ "547" => "PAY AT STORE"
123
+ }.freeze
124
+ private_constant :UPS_SURCHARGE_CODES
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'friendly_shipping/services/ups/parse_xml_response'
4
+ require 'friendly_shipping/services/ups/parse_money_element'
4
5
 
5
6
  module FriendlyShipping
6
7
  module Services
@@ -24,16 +25,16 @@ module FriendlyShipping
24
25
  sm.service_code == service_code && shipment.origin.country.in?(sm.origin_countries)
25
26
  end
26
27
  days_to_delivery = rated_shipment.at('GuaranteedDaysToDelivery').text.to_i
27
- currency = Money::Currency.new(rated_shipment.at('TotalCharges/CurrencyCode').text)
28
- total_cents = rated_shipment.at('TotalCharges/MonetaryValue').text.to_d * currency.subunit_to_unit
29
- insurance_price = rated_shipment.at('ServiceOptionsCharges/MonetaryValue').text.to_f
30
- negotiated_rate = rated_shipment.at(
31
- 'NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue'
32
- )&.text.to_f
28
+
29
+ total = ParseMoneyElement.call(rated_shipment.at('TotalCharges')).last
30
+ insurance_price = ParseMoneyElement.call(rated_shipment.at('ServiceOptionsCharges'))&.last
31
+ negotiated_rate = ParseMoneyElement.call(
32
+ rated_shipment.at('NegotiatedRates/NetSummaryCharges/GrandTotal')
33
+ )&.last
33
34
 
34
35
  FriendlyShipping::Rate.new(
35
36
  shipping_method: shipping_method,
36
- amounts: { total: Money.new(total_cents, currency) },
37
+ amounts: { total: total },
37
38
  warnings: [rated_shipment.at("RatedShipmentWarning")&.text].compact,
38
39
  errors: [],
39
40
  data: {
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads/result'
4
+ require 'friendly_shipping/services/ups/parse_money_element'
5
+
6
+ module FriendlyShipping
7
+ module Services
8
+ class Ups
9
+ class ParseShipmentAcceptResponse
10
+ extend Dry::Monads::Result::Mixin
11
+
12
+ class << self
13
+ def call(request:, response:)
14
+ parsing_result = ParseXMLResponse.call(response.body, 'ShipmentAcceptResponse')
15
+ parsing_result.fmap do |xml|
16
+ FriendlyShipping::ApiResult.new(
17
+ build_labels(xml),
18
+ original_request: request,
19
+ original_response: response
20
+ )
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_labels(xml)
27
+ packages = xml.xpath('//ShipmentAcceptResponse/ShipmentResults/PackageResults')
28
+ form_format = xml.at('Form/Image/ImageFormat/Code')&.text
29
+ encoded_form = xml.at('Form/Image/GraphicImage')&.text
30
+ decoded_form = encoded_form ? Base64.decode64(encoded_form) : nil
31
+ packages.map do |package|
32
+ cost_breakdown = build_cost_breakdown(package)
33
+ package_cost = cost_breakdown.values.any? ? cost_breakdown.values.sum : nil
34
+ encoded_label_data = package.at('LabelImage/GraphicImage')&.text
35
+ FriendlyShipping::Label.new(
36
+ tracking_number: package.at('TrackingNumber').text,
37
+ label_data: encoded_label_data ? Base64.decode64(encoded_label_data) : nil,
38
+ label_format: package.at('LabelImage/LabelImageFormat/Code')&.text,
39
+ cost: package_cost,
40
+ shipment_cost: get_shipment_cost(xml),
41
+ data: {
42
+ cost_breakdown: cost_breakdown,
43
+ negotiated_rate: get_negotiated_rate(xml),
44
+ form_format: form_format,
45
+ form: decoded_form,
46
+ customer_context: xml.xpath('//TransactionReference/CustomerContext')&.text
47
+ }.compact
48
+ )
49
+ end
50
+ end
51
+
52
+ def build_cost_breakdown(package)
53
+ cost_elements = [
54
+ package.at('BaseServiceCharge'),
55
+ package.at('ServiceOptionsCharges'),
56
+ package.xpath('ItemizedCharges')
57
+ ].flatten
58
+
59
+ cost_elements.map { |element| ParseMoneyElement.call(element) }.compact.to_h
60
+ end
61
+
62
+ def get_shipment_cost(shipment_xml)
63
+ total_charges_element = shipment_xml.at('ShipmentResults/ShipmentCharges/TotalCharges')
64
+ ParseMoneyElement.call(total_charges_element).last
65
+ end
66
+
67
+ def get_negotiated_rate(shipment_xml)
68
+ negotiated_total_element = shipment_xml.at('NegotiatedRates/NetSummaryCharges/GrandTotal')
69
+ ParseMoneyElement.call(negotiated_total_element)&.last
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/monads/result'
4
+
5
+ module FriendlyShipping
6
+ module Services
7
+ class Ups
8
+ class ParseShipmentConfirmResponse
9
+ def self.call(request:, response:)
10
+ parsing_result = ParseXMLResponse.call(response.body, 'ShipmentConfirmResponse')
11
+ parsing_result.fmap do |xml|
12
+ FriendlyShipping::ApiResult.new(
13
+ xml.root.at('ShipmentDigest').text,
14
+ original_request: request,
15
+ original_response: response
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -9,7 +9,8 @@ module FriendlyShipping
9
9
 
10
10
  class << self
11
11
  def call(response_body, expected_root_tag)
12
- xml = Nokogiri.XML(response_body)
12
+ xml = Nokogiri.XML(response_body, &:strict)
13
+
13
14
  if xml.root.nil? || xml.root.name != expected_root_tag
14
15
  Failure('Invalid document')
15
16
  end
@@ -37,14 +37,19 @@ module FriendlyShipping
37
37
  # StateProvinceCode required for negotiated rates but not otherwise, for some reason
38
38
  xml.StateProvinceCode(location.region.code) if location.region
39
39
  xml.CountryCode(location.country.code) if location.country
40
-
41
- # Quote residential rates by default. If UPS doesn't know if the address is residential or
42
- # commercial, it will quote a residential rate by default. Even with this flag being set,
43
- # if UPS knows the address is commercial it will often quote a commercial rate.
44
- #
45
- xml.ResidentialAddressIndicator unless location.commercial?
40
+ residential_address_indicator(xml, location)
46
41
  end
47
42
  end
43
+
44
+ private
45
+
46
+ def residential_address_indicator(xml, location)
47
+ # Quote residential rates by default. If UPS doesn't know if the address is residential or
48
+ # commercial, it will quote a residential rate by default. Even with this flag being set,
49
+ # if UPS knows the address is commercial it will often quote a commercial rate.
50
+ #
51
+ xml.ResidentialAddressIndicator unless location.commercial?
52
+ end
48
53
  end
49
54
  end
50
55
  end
@@ -4,12 +4,22 @@ module FriendlyShipping
4
4
  module Services
5
5
  class Ups
6
6
  class SerializePackageNode
7
- def self.call(xml:, package:)
7
+ def self.call(
8
+ xml:,
9
+ package:,
10
+ reference_numbers: {},
11
+ delivery_confirmation_code: nil,
12
+ shipper_release: false
13
+ )
8
14
  xml.Package do
9
15
  xml.PackagingType do
10
16
  xml.Code('02')
11
17
  end
12
18
 
19
+ if package.items.any?(&:description)
20
+ xml.Description(package.items.map(&:description).compact.join(', '))
21
+ end
22
+
13
23
  if package.dimensions.all? { |dim| !dim.value.zero? && !dim.value.infinite? }
14
24
  xml.Dimensions do
15
25
  xml.UnitOfMeasurement do
@@ -29,16 +39,21 @@ module FriendlyShipping
29
39
  xml.Weight([package.weight.convert_to(:pounds).value.to_f.round(2).ceil, 1].max)
30
40
  end
31
41
 
32
- if package.properties[:shipper_release]
33
- xml.PackageServiceOptions do
42
+ xml.PackageServiceOptions do
43
+ if shipper_release
34
44
  xml.ShipperReleaseIndicator
35
45
  end
46
+ if delivery_confirmation_code
47
+ xml.DeliveryConfirmation do
48
+ xml.DCISType(delivery_confirmation_code)
49
+ end
50
+ end
36
51
  end
37
52
 
38
- Array.wrap(package.properties[:reference_numbers]).each do |reference_number_info|
53
+ reference_numbers.each do |reference_code, reference_number|
39
54
  xml.ReferenceNumber do
40
- xml.Code(reference_number_info[:code] || "")
41
- xml.Value(reference_number_info[:value])
55
+ xml.Code(reference_code)
56
+ xml.Value(reference_number)
42
57
  end
43
58
  end
44
59
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Ups
6
+ class SerializeShipmentAcceptRequest
7
+ def self.call(digest:, options:)
8
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
9
+ xml.ShipmentAcceptRequest do
10
+ xml.Request do
11
+ xml.RequestAction('ShipAccept')
12
+ xml.SubVersion('1707')
13
+ if options.customer_context
14
+ xml.TransactionReference do
15
+ xml.CustomerContext(options.customer_context)
16
+ end
17
+ end
18
+ end
19
+ xml.ShipmentDigest(digest)
20
+ end
21
+ end
22
+ xml_builder.to_xml
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FriendlyShipping
4
+ module Services
5
+ class Ups
6
+ class SerializeShipmentAddressSnippet < SerializeAddressSnippet
7
+ class << self
8
+ private
9
+
10
+ def residential_address_indicator(xml, location)
11
+ # Shipment creation uses a different element to indicate residential addresses.
12
+ # Rates use ResidentialAddressIndicator whereas shipments use ResidentialAddress.
13
+ # Presence indicates residential address. Absence indicates commercial address.
14
+ #
15
+ xml.ResidentialAddressIndicator unless location.commercial?
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'friendly_shipping/services/ups/serialize_shipment_address_snippet'
4
+ require 'friendly_shipping/services/ups/serialize_package_node'
5
+
6
+ module FriendlyShipping
7
+ module Services
8
+ class Ups
9
+ class SerializeShipmentConfirmRequest
10
+ class << self
11
+ # Item options (only necessary for international shipping)
12
+ #
13
+ # @option description [String] A description of the item for the intl. invoice
14
+ # @option commodity_code [String] Commodity code for the item. See https://www.tariffnumber.com/
15
+ # @option value [Money] Price of the item
16
+ # @option unit_of_item_count [String] What kind of thing is one of this item? Example: Barrel, m3.
17
+ # See UPS docs for codes.
18
+ #
19
+ def call(
20
+ shipment:,
21
+ options:
22
+ )
23
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
24
+ xml.ShipmentConfirmRequest do
25
+ xml.Request do
26
+ xml.RequestAction('ShipConfirm')
27
+ # Required element controls level of address validation.
28
+ xml.RequestOption(options.validate_address ? 'validate' : 'nonvalidate')
29
+ xml.SubVersion('1707')
30
+ # Optional element to identify transactions between client and server.
31
+ if options.customer_context
32
+ xml.TransactionReference do
33
+ xml.CustomerContext(options.customer_context)
34
+ end
35
+ end
36
+ end
37
+
38
+ xml.Shipment do
39
+ xml.Service do
40
+ xml.Code(options.shipping_method.service_code)
41
+ end
42
+
43
+ xml.ShipTo do
44
+ SerializeShipmentAddressSnippet.call(xml: xml, location: shipment.destination)
45
+ end
46
+
47
+ # Required element. The company whose account is responsible for the label(s).
48
+ xml.Shipper do
49
+ SerializeShipmentAddressSnippet.call(xml: xml, location: options.shipper || shipment.origin)
50
+
51
+ xml.ShipperNumber(options.shipper_number)
52
+ end
53
+
54
+ if options.shipper || options.return_service_code
55
+ origin = options.return_service_code ? shipment.destination : shipment.origin
56
+ xml.ShipFrom do
57
+ SerializeShipmentAddressSnippet.call(xml: xml, location: origin)
58
+ end
59
+ end
60
+
61
+ if options.negotiated_rates
62
+ xml.RateInformation do
63
+ xml.NegotiatedRatesIndicator
64
+ end
65
+ end
66
+
67
+ # shipment.options.fetch(:reference_numbers, {}).each do |reference_code, reference_value|
68
+ # xml.ReferenceNumber do
69
+ # xml.Code(reference_code)
70
+ # xml.Value(reference_value)
71
+ # end
72
+ # end
73
+
74
+ if options.billing_options.prepay
75
+ xml.PaymentInformation do
76
+ xml.Prepaid do
77
+ build_billing_info_node(xml, options)
78
+ end
79
+ end
80
+ else
81
+ xml.ItemizedPaymentInformation do
82
+ xml.ShipmentCharge do
83
+ # Type '01' means 'Transportation'
84
+ # This node specifies who will be billed for transportation.
85
+ xml.Type('01')
86
+ build_billing_info_node(xml, options)
87
+ end
88
+ if international?(shipment) && options.terms_of_shipment_code == 'DDP'
89
+ # The shipper will cover duties and taxes
90
+ # Otherwise UPS will charge the receiver
91
+ xml.ShipmentCharge do
92
+ xml.Type('02') # Type '02' means 'Duties and Taxes'
93
+ build_billing_info_node(
94
+ xml,
95
+ options,
96
+ bill_to_consignee: true
97
+ )
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ if international?(shipment)
104
+ unless options.return_service_code
105
+ xml.SoldTo do
106
+ sold_to_location = options.sold_to || shipment.destination
107
+ SerializeShipmentAddressSnippet.call(xml: xml, location: sold_to_location)
108
+ end
109
+ end
110
+
111
+ if shipment.origin.country.code == 'US' && ['CA', 'PR'].include?(shipment.destination.country.code)
112
+ # Required for shipments from the US to Puerto Rico or Canada
113
+ # We'll assume USD as the origin country is the United States here.
114
+ xml.InvoiceLineTotal do
115
+ total_value = shipment.packages.inject(Money.new(0, 'USD')) do |shipment_sum, package|
116
+ shipment_sum + package.items.inject(Money.new(0, 'USD')) do |package_sum, item|
117
+ package_sum + (item.cost || Money.new(0, 'USD'))
118
+ end
119
+ end
120
+ xml.MonetaryValue(total_value.to_f)
121
+ end
122
+ end
123
+
124
+ contents_description = shipment.packages.flat_map do |package|
125
+ package.items.map(&:description)
126
+ end.compact.join(', ')
127
+
128
+ unless contents_description.empty?
129
+ xml.Description(contents_description)
130
+ end
131
+ end
132
+
133
+ if options.return_service_code
134
+ xml.ReturnService do
135
+ xml.Code(options.return_service_code)
136
+ end
137
+ end
138
+
139
+ xml.ShipmentServiceOptions do
140
+ xml.SaturdayDelivery if options.saturday_delivery
141
+ xml.UPScarbonneutralIndicator if options.carbon_neutral
142
+ if options.delivery_confirmation_code
143
+ xml.DeliveryConfirmation do
144
+ xml.DCISType(options.delivery_confirmation_code)
145
+ end
146
+ end
147
+
148
+ if international?(shipment)
149
+ build_international_forms(xml, shipment, options)
150
+ end
151
+ end
152
+
153
+ shipment.packages.each do |package|
154
+ package_options = options.options_for_package(package)
155
+ reference_numbers = allow_package_level_reference_numbers(shipment) ? package_options.reference_numbers : {}
156
+ delivery_confirmation_code = package_level_delivery_confirmation?(shipment) ? package_options.delivery_confirmation_code : nil
157
+ SerializePackageNode.call(
158
+ xml: xml,
159
+ package: package,
160
+ reference_numbers: reference_numbers,
161
+ delivery_confirmation_code: delivery_confirmation_code
162
+ )
163
+ end
164
+ end
165
+
166
+ xml.LabelSpecification do
167
+ xml.LabelStockSize do
168
+ xml.Height(options.label_size[0])
169
+ xml.Width(options.label_size[1])
170
+ end
171
+
172
+ xml.LabelPrintMethod do
173
+ xml.Code(options.label_format)
174
+ end
175
+
176
+ # API requires these only if returning a GIF formated label
177
+ if options.label_format == 'GIF'
178
+ xml.HTTPUserAgent('Mozilla/4.5')
179
+ xml.LabelImageFormat(options.label_format) do
180
+ xml.Code(options.label_format)
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ xml_builder.to_xml
187
+ end
188
+
189
+ private
190
+
191
+ # if the package is US -> US or PR -> PR the only type of reference numbers that are allowed are package-level
192
+ # Otherwise the only type of reference numbers that are allowed are shipment-level
193
+ def allow_package_level_reference_numbers(shipment)
194
+ origin, destination = shipment.origin.country, shipment.destination.country
195
+ [['US', 'US'], ['PR', 'PR']].include?([origin, destination])
196
+ end
197
+
198
+ # For certain origin/destination pairs, UPS allows each package in a shipment to have a specified
199
+ # delivery_confirmation option
200
+ # otherwise the delivery_confirmation option must be specified on the entire shipment.
201
+ # See Appendix P of UPS Shipping Package XML Developers Guide for the rules on which the logic below is based.
202
+ def package_level_delivery_confirmation?(shipment)
203
+ origin, destination = shipment.origin.country, shipment.destination.country
204
+ origin == destination || [['US', 'PR'], ['PR', 'US']].include?([origin, destination])
205
+ end
206
+
207
+ def build_billing_info_node(xml, options, bill_to_consignee: false)
208
+ billing_options = options.billing_options
209
+ if billing_options.bill_third_party
210
+ xml.BillThirdParty do
211
+ node_type = bill_to_consignee ? :BillThirdPartyConsignee : :BillThirdPartyShipper
212
+ xml.public_send(node_type) do
213
+ xml.AccountNumber(billing_options.billing_account)
214
+ xml.ThirdParty do
215
+ xml.Address do
216
+ xml.PostalCode(billing_options.billing_zip)
217
+ xml.CountryCode(billing_options.billing_country)
218
+ end
219
+ end
220
+ end
221
+ end
222
+ else
223
+ xml.BillShipper do
224
+ xml.AccountNumber(options.shipper_number)
225
+ end
226
+ end
227
+ end
228
+
229
+ def build_international_forms(xml, shipment, options)
230
+ return unless options.paperless_invoice
231
+
232
+ reason_for_export = options.return_service_code ? "RETURN" : options.reason_for_export
233
+
234
+ invoice_date = options.invoice_date || Date.current
235
+ xml.InternationalForms do
236
+ xml.FormType('01') # 01 is "Invoice"
237
+ xml.InvoiceDate(invoice_date.strftime('%Y%m%d'))
238
+ xml.ReasonForExport(reason_for_export)
239
+ xml.CurrencyCode(options.billing_options.currency || 'USD')
240
+
241
+ if options.terms_of_shipment_code
242
+ xml.TermsOfShipment(options.terms_of_shipment_code)
243
+ end
244
+ all_items = shipment.packages.map(&:items).map(&:to_a).flatten
245
+ all_item_options = shipment.packages.flat_map do |package|
246
+ package_options = options.options_for_package(package)
247
+ package.items.flat_map do |item|
248
+ package_options.options_for_item(item)
249
+ end
250
+ end
251
+
252
+ all_items.group_by(&:description).each do |description, items|
253
+ # This is a group of identically described items
254
+ reference_item = items.first
255
+ # Get the options for this item
256
+ item_options = all_item_options.detect { |o| o.item_id == reference_item.id } || LabelItemOptions.new(item_id: nil)
257
+
258
+ xml.Product do
259
+ cost = reference_item.cost || Money.new(0, 'USD')
260
+ xml.Description(description)
261
+ xml.CommodityCode(item_options.commodity_code)
262
+ xml.OriginCountryCode(shipment.origin.country.code)
263
+ xml.Unit do
264
+ xml.Value(cost * items.length)
265
+ xml.Number(items.length)
266
+ xml.UnitOfMeasurement do
267
+ xml.Code(item_options.product_unit_of_measure_code)
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+
275
+ def international?(shipment)
276
+ shipment.origin.country != shipment.destination.country
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end