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.
- checksums.yaml +4 -4
- data/.env.template +1 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +13 -4
- data/Gemfile +1 -0
- data/README.md +21 -2
- data/friendly_shipping.gemspec +1 -1
- data/lib/friendly_shipping/api_failure.rb +3 -0
- data/lib/friendly_shipping/api_result.rb +3 -0
- data/lib/friendly_shipping/carrier.rb +6 -0
- data/lib/friendly_shipping/http_client.rb +1 -0
- data/lib/friendly_shipping/item_options.rb +11 -0
- data/lib/friendly_shipping/label.rb +17 -9
- data/lib/friendly_shipping/package_options.rb +28 -0
- data/lib/friendly_shipping/rate.rb +9 -8
- data/lib/friendly_shipping/request.rb +4 -0
- data/lib/friendly_shipping/response.rb +3 -0
- data/lib/friendly_shipping/services/ship_engine.rb +10 -11
- data/lib/friendly_shipping/services/ship_engine/label_options.rb +34 -0
- data/lib/friendly_shipping/services/ship_engine/label_package_options.rb +28 -0
- data/lib/friendly_shipping/services/ship_engine/parse_label_response.rb +6 -1
- data/lib/friendly_shipping/services/ship_engine/parse_rate_estimate_response.rb +7 -7
- data/lib/friendly_shipping/services/ship_engine/rate_estimates_options.rb +25 -0
- data/lib/friendly_shipping/services/ship_engine/serialize_label_shipment.rb +15 -14
- data/lib/friendly_shipping/services/ship_engine/serialize_rate_estimate_request.rb +2 -2
- data/lib/friendly_shipping/services/ups.rb +47 -2
- data/lib/friendly_shipping/services/ups/label_billing_options.rb +41 -0
- data/lib/friendly_shipping/services/ups/label_item_options.rb +74 -0
- data/lib/friendly_shipping/services/ups/label_options.rb +165 -0
- data/lib/friendly_shipping/services/ups/label_package_options.rb +43 -0
- data/lib/friendly_shipping/services/ups/parse_money_element.rb +128 -0
- data/lib/friendly_shipping/services/ups/parse_rate_response.rb +8 -7
- data/lib/friendly_shipping/services/ups/parse_shipment_accept_response.rb +75 -0
- data/lib/friendly_shipping/services/ups/parse_shipment_confirm_response.rb +22 -0
- data/lib/friendly_shipping/services/ups/parse_xml_response.rb +2 -1
- data/lib/friendly_shipping/services/ups/serialize_address_snippet.rb +11 -6
- data/lib/friendly_shipping/services/ups/serialize_package_node.rb +21 -6
- data/lib/friendly_shipping/services/ups/serialize_shipment_accept_request.rb +27 -0
- data/lib/friendly_shipping/services/ups/serialize_shipment_address_snippet.rb +21 -0
- data/lib/friendly_shipping/services/ups/serialize_shipment_confirm_request.rb +282 -0
- data/lib/friendly_shipping/services/ups_freight.rb +76 -0
- data/lib/friendly_shipping/services/ups_freight/generate_commodity_information.rb +33 -0
- data/lib/friendly_shipping/services/ups_freight/generate_freight_rate_request_hash.rb +72 -0
- data/lib/friendly_shipping/services/ups_freight/generate_location_hash.rb +39 -0
- data/lib/friendly_shipping/services/ups_freight/generate_ups_security_hash.rb +23 -0
- data/lib/friendly_shipping/services/ups_freight/parse_freight_rate_response.rb +53 -0
- data/lib/friendly_shipping/services/ups_freight/parse_json_response.rb +38 -0
- data/lib/friendly_shipping/services/ups_freight/rates_item_options.rb +72 -0
- data/lib/friendly_shipping/services/ups_freight/rates_options.rb +54 -0
- data/lib/friendly_shipping/services/ups_freight/rates_package_options.rb +38 -0
- data/lib/friendly_shipping/services/ups_freight/shipping_methods.rb +25 -0
- data/lib/friendly_shipping/services/usps.rb +1 -1
- data/lib/friendly_shipping/services/usps/parse_xml_response.rb +1 -1
- data/lib/friendly_shipping/services/usps/serialize_rate_request.rb +0 -4
- data/lib/friendly_shipping/shipment_options.rb +23 -0
- data/lib/friendly_shipping/shipping_method.rb +7 -0
- data/lib/friendly_shipping/version.rb +1 -1
- 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
|
-
|
28
|
-
|
29
|
-
insurance_price = rated_shipment.at('ServiceOptionsCharges
|
30
|
-
negotiated_rate =
|
31
|
-
'NegotiatedRates/NetSummaryCharges/GrandTotal
|
32
|
-
)&.
|
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:
|
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(
|
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
|
-
|
33
|
-
|
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
|
-
|
53
|
+
reference_numbers.each do |reference_code, reference_number|
|
39
54
|
xml.ReferenceNumber do
|
40
|
-
xml.Code(
|
41
|
-
xml.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
|