friendly_shipping 0.10.1 → 0.10.2
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/Gemfile +1 -0
- data/README.md +16 -16
- data/lib/friendly_shipping/services/rl/parse_rate_quote_response.rb +3 -3
- data/lib/friendly_shipping/services/ups_json/parse_money_hash.rb +1 -0
- data/lib/friendly_shipping/services/usps_international/parse_rate_response.rb +3 -3
- data/lib/friendly_shipping/services/{usps → usps_international}/parse_xml_response.rb +1 -1
- data/lib/friendly_shipping/services/usps_international/serialize_rate_request.rb +1 -1
- data/lib/friendly_shipping/services/usps_ship/parse_rate_estimates_response.rb +1 -1
- data/lib/friendly_shipping/version.rb +1 -1
- metadata +3 -51
- data/lib/friendly_shipping/services/ups/label.rb +0 -20
- data/lib/friendly_shipping/services/ups/label_billing_options.rb +0 -41
- data/lib/friendly_shipping/services/ups/label_item_options.rb +0 -75
- data/lib/friendly_shipping/services/ups/label_options.rb +0 -174
- data/lib/friendly_shipping/services/ups/label_package_options.rb +0 -49
- data/lib/friendly_shipping/services/ups/parse_address_classification_response.rb +0 -29
- data/lib/friendly_shipping/services/ups/parse_address_validation_response.rb +0 -53
- data/lib/friendly_shipping/services/ups/parse_city_state_lookup_response.rb +0 -33
- data/lib/friendly_shipping/services/ups/parse_modifier_element.rb +0 -29
- data/lib/friendly_shipping/services/ups/parse_money_element.rb +0 -128
- data/lib/friendly_shipping/services/ups/parse_rate_response.rb +0 -101
- data/lib/friendly_shipping/services/ups/parse_shipment_accept_response.rb +0 -77
- data/lib/friendly_shipping/services/ups/parse_shipment_confirm_response.rb +0 -24
- data/lib/friendly_shipping/services/ups/parse_time_in_transit_response.rb +0 -56
- data/lib/friendly_shipping/services/ups/parse_void_shipment_response.rb +0 -24
- data/lib/friendly_shipping/services/ups/parse_xml_response.rb +0 -50
- data/lib/friendly_shipping/services/ups/rate_estimate_options.rb +0 -111
- data/lib/friendly_shipping/services/ups/rate_estimate_package_options.rb +0 -22
- data/lib/friendly_shipping/services/ups/serialize_access_request.rb +0 -20
- data/lib/friendly_shipping/services/ups/serialize_address_snippet.rb +0 -60
- data/lib/friendly_shipping/services/ups/serialize_address_validation_request.rb +0 -40
- data/lib/friendly_shipping/services/ups/serialize_city_state_lookup_request.rb +0 -26
- data/lib/friendly_shipping/services/ups/serialize_package_node.rb +0 -75
- data/lib/friendly_shipping/services/ups/serialize_rating_service_selection_request.rb +0 -98
- data/lib/friendly_shipping/services/ups/serialize_shipment_accept_request.rb +0 -27
- data/lib/friendly_shipping/services/ups/serialize_shipment_address_snippet.rb +0 -21
- data/lib/friendly_shipping/services/ups/serialize_shipment_confirm_request.rb +0 -285
- data/lib/friendly_shipping/services/ups/serialize_time_in_transit_request.rb +0 -56
- data/lib/friendly_shipping/services/ups/serialize_void_shipment_request.rb +0 -21
- data/lib/friendly_shipping/services/ups/shipping_methods.rb +0 -111
- data/lib/friendly_shipping/services/ups/timing_options.rb +0 -33
- data/lib/friendly_shipping/services/ups.rb +0 -218
- data/lib/friendly_shipping/services/usps/choose_package_rate.rb +0 -40
- data/lib/friendly_shipping/services/usps/machinable_package.rb +0 -50
- data/lib/friendly_shipping/services/usps/parse_address_validation_response.rb +0 -43
- data/lib/friendly_shipping/services/usps/parse_city_state_lookup_response.rb +0 -41
- data/lib/friendly_shipping/services/usps/parse_package_rate.rb +0 -159
- data/lib/friendly_shipping/services/usps/parse_rate_response.rb +0 -86
- data/lib/friendly_shipping/services/usps/parse_time_in_transit_response.rb +0 -240
- data/lib/friendly_shipping/services/usps/rate_estimate_options.rb +0 -26
- data/lib/friendly_shipping/services/usps/rate_estimate_package_options.rb +0 -66
- data/lib/friendly_shipping/services/usps/serialize_address_validation_request.rb +0 -25
- data/lib/friendly_shipping/services/usps/serialize_city_state_lookup_request.rb +0 -20
- data/lib/friendly_shipping/services/usps/serialize_rate_request.rb +0 -83
- data/lib/friendly_shipping/services/usps/serialize_time_in_transit_request.rb +0 -22
- data/lib/friendly_shipping/services/usps/shipping_methods.rb +0 -66
- data/lib/friendly_shipping/services/usps/timing_options.rb +0 -19
- data/lib/friendly_shipping/services/usps.rb +0 -115
@@ -1,218 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'nokogiri'
|
4
|
-
|
5
|
-
module FriendlyShipping
|
6
|
-
module Services
|
7
|
-
class Ups
|
8
|
-
include Dry::Monads::Result::Mixin
|
9
|
-
|
10
|
-
attr_reader :test, :key, :login, :password, :client
|
11
|
-
|
12
|
-
CARRIER = FriendlyShipping::Carrier.new(
|
13
|
-
id: 'ups',
|
14
|
-
name: 'United Parcel Service',
|
15
|
-
code: 'ups',
|
16
|
-
shipping_methods: SHIPPING_METHODS
|
17
|
-
)
|
18
|
-
|
19
|
-
TEST_URL = 'https://wwwcie.ups.com'
|
20
|
-
LIVE_URL = 'https://onlinetools.ups.com'
|
21
|
-
|
22
|
-
RESOURCES = {
|
23
|
-
address_validation: '/ups.app/xml/XAV',
|
24
|
-
city_state_lookup: '/ups.app/xml/AV',
|
25
|
-
rates: '/ups.app/xml/Rate',
|
26
|
-
ship_confirm: '/ups.app/xml/ShipConfirm',
|
27
|
-
ship_accept: '/ups.app/xml/ShipAccept',
|
28
|
-
timings: '/ups.app/xml/TimeInTransit',
|
29
|
-
void: '/ups.app/xml/Void',
|
30
|
-
}.freeze
|
31
|
-
|
32
|
-
def initialize(key:, login:, password:, test: true, client: HttpClient.new)
|
33
|
-
@key = key
|
34
|
-
@login = login
|
35
|
-
@password = password
|
36
|
-
@test = test
|
37
|
-
@client = client
|
38
|
-
end
|
39
|
-
|
40
|
-
def carriers
|
41
|
-
Success([CARRIER])
|
42
|
-
end
|
43
|
-
|
44
|
-
# Get rates for a shipment
|
45
|
-
# @param [Physical::Shipment] shipment The shipment we want to get rates for
|
46
|
-
# @param [FriendlyShipping::Services::Ups::RateEstimateOptions] options What options
|
47
|
-
# to use for this rate estimate call
|
48
|
-
# @return [Result<ApiResult<Array<Rate>>>] The rates returned from UPS encoded in a
|
49
|
-
# `FriendlyShipping::ApiResult` object.
|
50
|
-
def rate_estimates(shipment, options:, debug: false)
|
51
|
-
rate_request_xml = SerializeRatingServiceSelectionRequest.call(shipment: shipment, options: options)
|
52
|
-
url = base_url + RESOURCES[:rates]
|
53
|
-
request = FriendlyShipping::Request.new(
|
54
|
-
url: url,
|
55
|
-
http_method: "POST",
|
56
|
-
body: access_request_xml + rate_request_xml,
|
57
|
-
readable_body: rate_request_xml,
|
58
|
-
debug: debug
|
59
|
-
)
|
60
|
-
|
61
|
-
client.post(request).bind do |response|
|
62
|
-
ParseRateResponse.call(response: response, request: request, shipment: shipment)
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
# Get timing information for a shipment
|
67
|
-
# @param [Physical::Shipment] shipment The shipment we want to estimate timings for
|
68
|
-
# @param [FriendlyShipping::Services::Ups::TimingOptions] options Options for this call
|
69
|
-
def timings(shipment, options:, debug: false)
|
70
|
-
time_in_transit_request_xml = SerializeTimeInTransitRequest.call(
|
71
|
-
shipment: shipment,
|
72
|
-
options: options
|
73
|
-
)
|
74
|
-
time_in_transit_url = base_url + RESOURCES[:timings]
|
75
|
-
|
76
|
-
request = FriendlyShipping::Request.new(
|
77
|
-
url: time_in_transit_url,
|
78
|
-
http_method: "POST",
|
79
|
-
body: access_request_xml + time_in_transit_request_xml,
|
80
|
-
readable_body: time_in_transit_request_xml,
|
81
|
-
debug: debug
|
82
|
-
)
|
83
|
-
|
84
|
-
client.post(request).bind do |response|
|
85
|
-
ParseTimeInTransitResponse.call(response: response, request: request)
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
def labels(shipment, options:, debug: false)
|
90
|
-
## Method body starts
|
91
|
-
ship_confirm_request_xml = SerializeShipmentConfirmRequest.call(
|
92
|
-
shipment: shipment,
|
93
|
-
options: options
|
94
|
-
)
|
95
|
-
ship_confirm_url = base_url + RESOURCES[:ship_confirm]
|
96
|
-
|
97
|
-
ship_confirm_request = FriendlyShipping::Request.new(
|
98
|
-
url: ship_confirm_url,
|
99
|
-
http_method: "POST",
|
100
|
-
body: access_request_xml + ship_confirm_request_xml,
|
101
|
-
readable_body: ship_confirm_request_xml,
|
102
|
-
debug: debug
|
103
|
-
)
|
104
|
-
|
105
|
-
client.post(ship_confirm_request).bind do |ship_confirm_response|
|
106
|
-
ParseShipmentConfirmResponse.call(
|
107
|
-
request: ship_confirm_request,
|
108
|
-
response: ship_confirm_response
|
109
|
-
)
|
110
|
-
end.bind do |ship_confirm_result|
|
111
|
-
ship_accept_url = base_url + RESOURCES[:ship_accept]
|
112
|
-
ship_accept_request_xml = SerializeShipmentAcceptRequest.call(
|
113
|
-
digest: ship_confirm_result.data,
|
114
|
-
options: options
|
115
|
-
)
|
116
|
-
|
117
|
-
ship_accept_request = FriendlyShipping::Request.new(
|
118
|
-
url: ship_accept_url,
|
119
|
-
http_method: "POST",
|
120
|
-
body: access_request_xml + ship_accept_request_xml,
|
121
|
-
readable_body: ship_accept_request_xml,
|
122
|
-
debug: debug
|
123
|
-
)
|
124
|
-
|
125
|
-
client.post(ship_accept_request).bind do |ship_accept_response|
|
126
|
-
ParseShipmentAcceptResponse.call(request: ship_accept_request, response: ship_accept_response)
|
127
|
-
end
|
128
|
-
end
|
129
|
-
end
|
130
|
-
|
131
|
-
# Validate an address.
|
132
|
-
# @param [Physical::Location] location The address we want to verify
|
133
|
-
# @return [Result<ApiResult<Array<Physical::Location>>>] The response data from UPS encoded in a
|
134
|
-
# `Physical::Location` object. Name and Company name are always nil, the
|
135
|
-
# address lines will be made conformant to what UPS considers right. The returned location will
|
136
|
-
# have the address_type set if possible.
|
137
|
-
def address_validation(location, debug: false)
|
138
|
-
address_validation_request_xml = SerializeAddressValidationRequest.call(location: location)
|
139
|
-
url = base_url + RESOURCES[:address_validation]
|
140
|
-
request = FriendlyShipping::Request.new(
|
141
|
-
url: url,
|
142
|
-
http_method: "POST",
|
143
|
-
body: access_request_xml + address_validation_request_xml,
|
144
|
-
readable_body: address_validation_request_xml,
|
145
|
-
debug: debug
|
146
|
-
)
|
147
|
-
|
148
|
-
client.post(request).bind do |response|
|
149
|
-
ParseAddressValidationResponse.call(response: response, request: request)
|
150
|
-
end
|
151
|
-
end
|
152
|
-
|
153
|
-
# Classify an address.
|
154
|
-
# @param [Physical::Location] location The address we want to classify
|
155
|
-
# @return [Result<ApiResult<String>>] Either `"commercial"`, `"residential"`, or `"unknown"`
|
156
|
-
def address_classification(location, debug: false)
|
157
|
-
address_validation_request_xml = SerializeAddressValidationRequest.call(location: location)
|
158
|
-
url = base_url + RESOURCES[:address_validation]
|
159
|
-
request = FriendlyShipping::Request.new(
|
160
|
-
url: url,
|
161
|
-
http_method: "POST",
|
162
|
-
body: access_request_xml + address_validation_request_xml,
|
163
|
-
readable_body: address_validation_request_xml,
|
164
|
-
debug: debug
|
165
|
-
)
|
166
|
-
|
167
|
-
client.post(request).bind do |response|
|
168
|
-
ParseAddressClassificationResponse.call(response: response, request: request)
|
169
|
-
end
|
170
|
-
end
|
171
|
-
|
172
|
-
# Find city and state for a given ZIP code
|
173
|
-
# @param [Physical::Location] location A location object with country and ZIP code set
|
174
|
-
# @return [Result<ApiResult<Array<Physical::Location>>>] The response data from UPS encoded in a
|
175
|
-
# `Physical::Location` object. Country, City and ZIP code will be set, everything else nil.
|
176
|
-
def city_state_lookup(location, debug: false)
|
177
|
-
city_state_lookup_request_xml = SerializeCityStateLookupRequest.call(location: location)
|
178
|
-
url = base_url + RESOURCES[:city_state_lookup]
|
179
|
-
request = FriendlyShipping::Request.new(
|
180
|
-
url: url,
|
181
|
-
http_method: "POST",
|
182
|
-
body: access_request_xml + city_state_lookup_request_xml,
|
183
|
-
readable_body: city_state_lookup_request_xml,
|
184
|
-
debug: debug
|
185
|
-
)
|
186
|
-
|
187
|
-
client.post(request).bind do |response|
|
188
|
-
ParseCityStateLookupResponse.call(response: response, request: request, location: location)
|
189
|
-
end
|
190
|
-
end
|
191
|
-
|
192
|
-
def void(label, debug: false)
|
193
|
-
url = base_url + RESOURCES[:void]
|
194
|
-
void_request_xml = SerializeVoidShipmentRequest.call(label: label)
|
195
|
-
request = FriendlyShipping::Request.new(
|
196
|
-
url: url,
|
197
|
-
http_method: "POST",
|
198
|
-
body: access_request_xml + void_request_xml,
|
199
|
-
readable_body: void_request_xml,
|
200
|
-
debug: debug
|
201
|
-
)
|
202
|
-
client.post(request).bind do |response|
|
203
|
-
ParseVoidShipmentResponse.call(request: request, response: response)
|
204
|
-
end
|
205
|
-
end
|
206
|
-
|
207
|
-
private
|
208
|
-
|
209
|
-
def access_request_xml
|
210
|
-
SerializeAccessRequest.call(key: key, login: login, password: password)
|
211
|
-
end
|
212
|
-
|
213
|
-
def base_url
|
214
|
-
test ? TEST_URL : LIVE_URL
|
215
|
-
end
|
216
|
-
end
|
217
|
-
end
|
218
|
-
end
|
@@ -1,40 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module FriendlyShipping
|
4
|
-
module Services
|
5
|
-
class Usps
|
6
|
-
class ChoosePackageRate
|
7
|
-
class CannotDetermineRate < StandardError; end
|
8
|
-
# Some shipping rates use 'Flat Rate Boxes', indicating that
|
9
|
-
# they are available for ALL flat rate boxes.
|
10
|
-
FLAT_RATE_BOX = /Flat Rate Box/i
|
11
|
-
|
12
|
-
# Select the corresponding rate for a package from all the rates USPS returns to us
|
13
|
-
#
|
14
|
-
# @param [FriendlyShipping::ShippingMethod] shipping_method The shipping method we want to filter by
|
15
|
-
# @param [Array<FriendlyShipping::Rate>] rates The rates we select from
|
16
|
-
# @param [FriendlyShipping::PackageOptions] package_options The package options we want to match with a rate
|
17
|
-
#
|
18
|
-
# @return [FriendlyShipping::Rate] The rate that most closely matches our package
|
19
|
-
def self.call(shipping_method, rates, package_options)
|
20
|
-
# Keep all rates with the requested shipping method
|
21
|
-
rates_with_this_shipping_method = rates.select { |r| r.shipping_method == shipping_method }
|
22
|
-
|
23
|
-
# Keep only rates with the package type of this package
|
24
|
-
rates_with_this_package_type = rates_with_this_shipping_method.select do |r|
|
25
|
-
r.data[:box_name] == package_options.box_name
|
26
|
-
end
|
27
|
-
|
28
|
-
# Filter by our package's `hold_for_pickup` option
|
29
|
-
rates_with_this_hold_for_pickup_option = rates_with_this_package_type.select do |r|
|
30
|
-
r.data[:hold_for_pickup] == package_options.hold_for_pickup
|
31
|
-
end
|
32
|
-
|
33
|
-
# At this point, we have one or two rates left, and they're similar enough.
|
34
|
-
# Once this poses an actual problem, we'll fix it.
|
35
|
-
rates_with_this_hold_for_pickup_option.first
|
36
|
-
end
|
37
|
-
end
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
@@ -1,50 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module FriendlyShipping
|
4
|
-
module Services
|
5
|
-
class Usps
|
6
|
-
# USPS has certain size and weight requirements for packages to
|
7
|
-
# be considered machinable. Machinable packages are generally
|
8
|
-
# less expensive to ship. For more information see:
|
9
|
-
# https://pe.usps.com/BusinessMail101?ViewName=Parcels
|
10
|
-
#
|
11
|
-
class MachinablePackage
|
12
|
-
attr_reader :package
|
13
|
-
|
14
|
-
MIN_LENGTH = Measured::Length(6, :inches)
|
15
|
-
MIN_WIDTH = Measured::Length(3, :inches)
|
16
|
-
MIN_HEIGHT = Measured::Length(0.25, :inches)
|
17
|
-
|
18
|
-
MAX_LENGTH = Measured::Length(27, :inches)
|
19
|
-
MAX_WIDTH = Measured::Length(17, :inches)
|
20
|
-
MAX_HEIGHT = Measured::Length(17, :inches)
|
21
|
-
|
22
|
-
MAX_WEIGHT = Measured::Weight(25, :pounds)
|
23
|
-
|
24
|
-
# @param [Physical::Package] package
|
25
|
-
def initialize(package)
|
26
|
-
@package = package
|
27
|
-
end
|
28
|
-
|
29
|
-
def machinable?
|
30
|
-
at_least_minimum && at_most_maximum
|
31
|
-
end
|
32
|
-
|
33
|
-
private
|
34
|
-
|
35
|
-
def at_least_minimum
|
36
|
-
package.length >= MIN_LENGTH &&
|
37
|
-
package.width >= MIN_WIDTH &&
|
38
|
-
package.height >= MIN_HEIGHT
|
39
|
-
end
|
40
|
-
|
41
|
-
def at_most_maximum
|
42
|
-
package.length <= MAX_LENGTH &&
|
43
|
-
package.width <= MAX_WIDTH &&
|
44
|
-
package.height <= MAX_HEIGHT &&
|
45
|
-
package.weight <= MAX_WEIGHT
|
46
|
-
end
|
47
|
-
end
|
48
|
-
end
|
49
|
-
end
|
50
|
-
end
|
@@ -1,43 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module FriendlyShipping
|
4
|
-
module Services
|
5
|
-
class Usps
|
6
|
-
class ParseAddressValidationResponse
|
7
|
-
class << self
|
8
|
-
# Parse a response from USPS' address validation API
|
9
|
-
#
|
10
|
-
# @param [FriendlyShipping::Request] request The request that was used to obtain this Response
|
11
|
-
# @param [FriendlyShipping::Response] response The response that USPS returned
|
12
|
-
# @return [Result<FriendlyShipping::AddressValidationResult>]
|
13
|
-
def call(request:, response:)
|
14
|
-
# Filter out error responses and directly return a failure
|
15
|
-
parsing_result = ParseXMLResponse.call(
|
16
|
-
request: request,
|
17
|
-
response: response,
|
18
|
-
expected_root_tag: 'AddressValidateResponse'
|
19
|
-
)
|
20
|
-
parsing_result.fmap do |xml|
|
21
|
-
address = xml.root.at('Address')
|
22
|
-
suggestions = [
|
23
|
-
Physical::Location.new(
|
24
|
-
address1: address&.at('Address2')&.text, # USPS swaps Address1 and Address2 in the response
|
25
|
-
address2: address&.at('Address1')&.text,
|
26
|
-
city: address&.at('City')&.text,
|
27
|
-
region: address&.at('State')&.text,
|
28
|
-
zip: address&.at('Zip5')&.text,
|
29
|
-
country: 'US'
|
30
|
-
)
|
31
|
-
]
|
32
|
-
FriendlyShipping::ApiResult.new(
|
33
|
-
suggestions,
|
34
|
-
original_request: request,
|
35
|
-
original_response: response
|
36
|
-
)
|
37
|
-
end
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|
@@ -1,41 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module FriendlyShipping
|
4
|
-
module Services
|
5
|
-
class Usps
|
6
|
-
class ParseCityStateLookupResponse
|
7
|
-
class << self
|
8
|
-
# Parse a response from USPS' city/state lookup API
|
9
|
-
#
|
10
|
-
# @param [FriendlyShipping::Request] request The request that was used to obtain this Response
|
11
|
-
# @param [FriendlyShipping::Response] response The response that USPS returned
|
12
|
-
# @return [Result<FriendlyShipping::AddressValidationResult>]
|
13
|
-
def call(request:, response:)
|
14
|
-
# Filter out error responses and directly return a failure
|
15
|
-
parsing_result = ParseXMLResponse.call(
|
16
|
-
request: request,
|
17
|
-
response: response,
|
18
|
-
expected_root_tag: 'CityStateLookupResponse'
|
19
|
-
)
|
20
|
-
parsing_result.fmap do |xml|
|
21
|
-
address = xml.root.at('ZipCode')
|
22
|
-
suggestions = [
|
23
|
-
Physical::Location.new(
|
24
|
-
city: address&.at('City')&.text,
|
25
|
-
region: address&.at('State')&.text,
|
26
|
-
zip: address&.at('Zip5')&.text,
|
27
|
-
country: 'US'
|
28
|
-
)
|
29
|
-
]
|
30
|
-
FriendlyShipping::ApiResult.new(
|
31
|
-
suggestions,
|
32
|
-
original_request: request,
|
33
|
-
original_response: response,
|
34
|
-
)
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
41
|
-
end
|
@@ -1,159 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module FriendlyShipping
|
4
|
-
module Services
|
5
|
-
class Usps
|
6
|
-
class ParsePackageRate
|
7
|
-
# USPS returns all the info about a rate in a long string with a bit of gibberish.
|
8
|
-
ESCAPING_AND_SYMBOLS = /<\S*>/
|
9
|
-
|
10
|
-
# At the beginning of the long String, USPS keeps a copy of its own name. We know we're dealing with
|
11
|
-
# them though, so we can filter that out, too.
|
12
|
-
LEADING_USPS = /^USPS /
|
13
|
-
|
14
|
-
# This combines all the things we want to filter out.
|
15
|
-
SERVICE_NAME_SUBSTITUTIONS = /#{ESCAPING_AND_SYMBOLS}|#{LEADING_USPS}/
|
16
|
-
|
17
|
-
# Often we get a multitude of rates for the same service given some combination of
|
18
|
-
# Box type and (see below) and "Hold for Pickup" service. This creates a regular expression
|
19
|
-
# with groups named after the keys from the `Usps::CONTAINERS` constant.
|
20
|
-
# Unfortunately, the keys don't correspond directly to the codes we use when serializing the
|
21
|
-
# request.
|
22
|
-
BOX_REGEX = {
|
23
|
-
flat_rate_boxes: 'Flat Rate Boxes',
|
24
|
-
large_flat_rate_box: 'Large Flat Rate Box',
|
25
|
-
medium_flat_rate_box: 'Medium Flat Rate Box',
|
26
|
-
small_flat_rate_box: 'Small Flat Rate Box',
|
27
|
-
regional_rate_box_a: 'Regional Rate Box A',
|
28
|
-
regional_rate_box_b: 'Regional Rate Box B',
|
29
|
-
regional_rate_box_c: 'Regional Rate Box C',
|
30
|
-
flat_rate_envelope: 'Flat Rate Envelope',
|
31
|
-
legal_flat_rate_envelope: 'Legal Flat Rate Envelope',
|
32
|
-
padded_flat_rate_envelope: 'Padded Flat Rate Envelope',
|
33
|
-
gift_card_flat_rate_envelope: 'Gift Card Flat Rate Envelope',
|
34
|
-
window_flat_rate_envelope: 'Window Flat Rate Envelope',
|
35
|
-
small_flat_rate_envelope: 'Small Flat Rate Envelope',
|
36
|
-
large_envelope: 'Large Envelope',
|
37
|
-
parcel: 'Parcel',
|
38
|
-
postcards: 'Postcards'
|
39
|
-
}.map { |k, v| "(?<#{k}>#{v})" }.join("|").freeze
|
40
|
-
|
41
|
-
# We use this for identifying rates that use the Hold for Pickup service.
|
42
|
-
HOLD_FOR_PICKUP = /Hold for Pickup/i
|
43
|
-
|
44
|
-
# For most rate options, USPS will return how many business days it takes to deliver this
|
45
|
-
# package in the format "{1,2,3}-Day". We can filter this out using the below Regex.
|
46
|
-
DAYS_TO_DELIVERY = /(?<days>\d)-Day/
|
47
|
-
|
48
|
-
# When delivering to military ZIP codes, we don't actually get a timing estimate, but instead the string
|
49
|
-
# "Military". We use this to indicate that this rate is for a military zip code in the rates' data Hash.
|
50
|
-
MILITARY = /MILITARY/i
|
51
|
-
|
52
|
-
# The tags used in the rate node that we get information from.
|
53
|
-
SERVICE_CODE_TAG = 'CLASSID'
|
54
|
-
SERVICE_NAME_TAG = 'MailService'
|
55
|
-
RATE_TAG = 'Rate'
|
56
|
-
COMMERCIAL_RATE_TAG = 'CommercialRate'
|
57
|
-
COMMERCIAL_PLUS_RATE_TAG = 'CommercialPlusRate'
|
58
|
-
DIMENSIONAL_WEIGHT_RATE = 'DimensionalWeightRate'
|
59
|
-
FEES = './/Fees/Fee'
|
60
|
-
CURRENCY = Money::Currency.new('USD').freeze
|
61
|
-
|
62
|
-
class << self
|
63
|
-
def call(rate_node, package, package_options)
|
64
|
-
# "A mail class identifier for the postage returned. Not necessarily unique within a <Package/>."
|
65
|
-
# (from the USPS docs). We save this on the data Hash, but do not use it for identifying shipping methods.
|
66
|
-
service_code = rate_node.attributes[SERVICE_CODE_TAG].value
|
67
|
-
|
68
|
-
# The long string discussed above.
|
69
|
-
service_name = rate_node.at(SERVICE_NAME_TAG).text
|
70
|
-
|
71
|
-
# Does this rate assume Hold for Pickup service?
|
72
|
-
hold_for_pickup = service_name.match?(HOLD_FOR_PICKUP)
|
73
|
-
|
74
|
-
# Is the destination a military ZIP code?
|
75
|
-
military = service_name.match?(MILITARY)
|
76
|
-
|
77
|
-
# If we get a days-to-delivery indication, save it in the `days_to_delivery` variable.
|
78
|
-
days_to_delivery_match = service_name.match(DAYS_TO_DELIVERY)
|
79
|
-
days_to_delivery = if days_to_delivery_match
|
80
|
-
days_to_delivery_match.named_captures.values.first.to_i
|
81
|
-
end
|
82
|
-
|
83
|
-
# Clean up the long string
|
84
|
-
service_name.gsub!(SERVICE_NAME_SUBSTITUTIONS, '')
|
85
|
-
|
86
|
-
# Some USPS services only offer commercial pricing. Unfortunately, USPS then returns a retail rate of 0.
|
87
|
-
# In these cases, return the commercial rate instead of the normal rate.
|
88
|
-
#
|
89
|
-
# Some rates are available in both commercial and retail pricing - if we want the commercial pricing here,
|
90
|
-
# we need to specify the commercial_pricing property on the `Physical::Package`.
|
91
|
-
#
|
92
|
-
commercial_rate_requested_or_rate_is_zero = package_options.commercial_pricing || rate_node.at(RATE_TAG).text.to_d.zero?
|
93
|
-
commercial_rate_available = rate_node.at(COMMERCIAL_RATE_TAG) || rate_node.at(COMMERCIAL_PLUS_RATE_TAG)
|
94
|
-
|
95
|
-
rate_value =
|
96
|
-
if commercial_rate_requested_or_rate_is_zero && commercial_rate_available
|
97
|
-
commercial_rate = rate_node.at(COMMERCIAL_RATE_TAG)&.text.to_d
|
98
|
-
commercial_rate.zero? ? rate_node.at(COMMERCIAL_PLUS_RATE_TAG).text.to_d : commercial_rate
|
99
|
-
else
|
100
|
-
rate_node.at(RATE_TAG).text.to_d
|
101
|
-
end
|
102
|
-
|
103
|
-
# The rate expressed as a RubyMoney objext
|
104
|
-
rate = Money.new(rate_value * CURRENCY.subunit_to_unit, CURRENCY)
|
105
|
-
|
106
|
-
# Which shipping method does this rate belong to? We first try to match a rate to a shipping method
|
107
|
-
# by class ID (the CLASSID attribute in the USPS API rate response). Not every shipping method
|
108
|
-
# has a class ID defined, and a shipping method can have multiple class IDs (for example, Priority
|
109
|
-
# Express has different class IDs for standard, hold for pickup, and Sunday/Holiday delivery).
|
110
|
-
#
|
111
|
-
# If we don't find a match for class ID, we next try to match a rate to a shipping method using the
|
112
|
-
# shipping method's service code. The USPS API rate response includes a name for each rate in the
|
113
|
-
# MailService element. We match to see if the name starts with the given value. For example:
|
114
|
-
# `Priority Mail Express 2-day™`
|
115
|
-
#
|
116
|
-
shipping_method =
|
117
|
-
SHIPPING_METHODS.detect { |sm| sm.data[:class_ids]&.include?(service_code) } ||
|
118
|
-
SHIPPING_METHODS.detect { |sm| service_name.tr('-', ' ').upcase.starts_with?(sm.service_code) }
|
119
|
-
|
120
|
-
# We find out the box name using a bit of Regex magic using named captures. See the `BOX_REGEX`
|
121
|
-
# constant above.
|
122
|
-
box_name_match = service_name.match(/#{BOX_REGEX}/)
|
123
|
-
box_name = box_name_match ? box_name_match.named_captures.compact.keys.last.to_sym : :variable
|
124
|
-
|
125
|
-
dimensional_weight_rate = rate_node.at(DIMENSIONAL_WEIGHT_RATE)&.text&.to_i
|
126
|
-
|
127
|
-
fees = rate_node.xpath(FEES).map do |fee_node|
|
128
|
-
type = fee_node.at('FeeType').text
|
129
|
-
price = fee_node.at('FeePrice').text.to_d
|
130
|
-
{
|
131
|
-
type: type,
|
132
|
-
price: Money.new(price * CURRENCY.subunit_to_unit, CURRENCY)
|
133
|
-
}
|
134
|
-
end
|
135
|
-
|
136
|
-
# Combine all the gathered information in a FriendlyShipping::Rate object.
|
137
|
-
# Careful: This rate is only for one package within the shipment, and we get multiple
|
138
|
-
# rates per package for the different shipping method/box/hold for pickup combinations.
|
139
|
-
FriendlyShipping::Rate.new(
|
140
|
-
shipping_method: shipping_method,
|
141
|
-
amounts: { package.id => rate },
|
142
|
-
data: {
|
143
|
-
package: package,
|
144
|
-
box_name: box_name,
|
145
|
-
hold_for_pickup: hold_for_pickup,
|
146
|
-
days_to_delivery: days_to_delivery,
|
147
|
-
military: military,
|
148
|
-
full_mail_service: service_name,
|
149
|
-
service_code: service_code,
|
150
|
-
dimensional_weight_rate: dimensional_weight_rate,
|
151
|
-
fees: fees
|
152
|
-
}
|
153
|
-
)
|
154
|
-
end
|
155
|
-
end
|
156
|
-
end
|
157
|
-
end
|
158
|
-
end
|
159
|
-
end
|
@@ -1,86 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module FriendlyShipping
|
4
|
-
module Services
|
5
|
-
class Usps
|
6
|
-
class ParseRateResponse
|
7
|
-
class BoxNotFoundError < StandardError; end
|
8
|
-
|
9
|
-
class << self
|
10
|
-
# Parse a response from USPS' rating API
|
11
|
-
#
|
12
|
-
# @param [FriendlyShipping::Request] request The request that was used to obtain this Response
|
13
|
-
# @param [FriendlyShipping::Response] response The response that USPS returned
|
14
|
-
# @param [Physical::Shipment] shipment The shipment object we're trying to get results for
|
15
|
-
# @param [FriendlyShipping::Services::Usps::RateEstimateOptions] options The options we sent with this request
|
16
|
-
# @return [Result<ApiResult<Array<FriendlyShipping::Rate>>>] When successfully parsing, an array of rates in a Success Monad.
|
17
|
-
def call(request:, response:, shipment:, options:)
|
18
|
-
# Filter out error responses and directly return a failure
|
19
|
-
parsing_result = ParseXMLResponse.call(
|
20
|
-
request: request,
|
21
|
-
response: response,
|
22
|
-
expected_root_tag: 'RateV4Response'
|
23
|
-
)
|
24
|
-
parsing_result.fmap do |xml|
|
25
|
-
# Get all the possible rates for each package
|
26
|
-
rates_by_package = rates_from_response_node(xml, shipment, options)
|
27
|
-
|
28
|
-
rates = SHIPPING_METHODS.map do |shipping_method|
|
29
|
-
# For every package ...
|
30
|
-
matching_rates = rates_by_package.map do |package, package_rates|
|
31
|
-
# ... choose the rate that fits this package best.
|
32
|
-
|
33
|
-
package_options = options.options_for_package(package)
|
34
|
-
|
35
|
-
ChoosePackageRate.call(shipping_method, package_rates, package_options)
|
36
|
-
end.compact # Some shipping rates are not available for every shipping method.
|
37
|
-
|
38
|
-
# in this case, go to the next shipping method.
|
39
|
-
next if matching_rates.empty?
|
40
|
-
|
41
|
-
# return one rate for all packages with the amount keys being the package IDs.
|
42
|
-
FriendlyShipping::Rate.new(
|
43
|
-
amounts: matching_rates.map(&:amounts).reduce({}, :merge),
|
44
|
-
shipping_method: shipping_method,
|
45
|
-
data: matching_rates.first.data
|
46
|
-
)
|
47
|
-
end.compact
|
48
|
-
|
49
|
-
ApiResult.new(
|
50
|
-
rates,
|
51
|
-
original_request: request,
|
52
|
-
original_response: response
|
53
|
-
)
|
54
|
-
end
|
55
|
-
end
|
56
|
-
|
57
|
-
private
|
58
|
-
|
59
|
-
PACKAGE_NODE_XPATH = '//Package'
|
60
|
-
SERVICE_NODE_NAME = 'Postage'
|
61
|
-
|
62
|
-
# Iterate over all packages and parse the rates for each package
|
63
|
-
#
|
64
|
-
# @param [Nokogiri::XML::Node] xml The XML document containing packages and rates
|
65
|
-
# @param [Physical::Shipment] shipment The shipment we're trying to get rates for
|
66
|
-
#
|
67
|
-
# @return [Hash<Physical::Package => Array<FriendlyShipping::Rate>>]
|
68
|
-
def rates_from_response_node(xml, shipment, options)
|
69
|
-
xml.xpath(PACKAGE_NODE_XPATH).each_with_object({}) do |package_node, result|
|
70
|
-
package_id = package_node['ID']
|
71
|
-
corresponding_package = shipment.packages[package_id.to_i]
|
72
|
-
package_options = options.options_for_package(corresponding_package)
|
73
|
-
# There should always be a package in the original shipment that corresponds to the package ID
|
74
|
-
# in the USPS response.
|
75
|
-
raise BoxNotFoundError if corresponding_package.nil?
|
76
|
-
|
77
|
-
result[corresponding_package] = package_node.xpath(SERVICE_NODE_NAME).map do |service_node|
|
78
|
-
ParsePackageRate.call(service_node, corresponding_package, package_options)
|
79
|
-
end
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|
84
|
-
end
|
85
|
-
end
|
86
|
-
end
|