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