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,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