reactive_freight 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +166 -0
  5. data/Rakefile +8 -0
  6. data/accessorial_symbols.txt +95 -0
  7. data/lib/reactive_freight.rb +21 -0
  8. data/lib/reactive_freight/carrier.rb +62 -0
  9. data/lib/reactive_freight/carriers.rb +18 -0
  10. data/lib/reactive_freight/carriers/btvp.rb +384 -0
  11. data/lib/reactive_freight/carriers/clni.rb +59 -0
  12. data/lib/reactive_freight/carriers/ctbv.rb +35 -0
  13. data/lib/reactive_freight/carriers/dphe.rb +296 -0
  14. data/lib/reactive_freight/carriers/drrq.rb +303 -0
  15. data/lib/reactive_freight/carriers/fcsy.rb +24 -0
  16. data/lib/reactive_freight/carriers/fwda.rb +243 -0
  17. data/lib/reactive_freight/carriers/jfj_transportation.rb +11 -0
  18. data/lib/reactive_freight/carriers/pens.rb +135 -0
  19. data/lib/reactive_freight/carriers/rdfs.rb +320 -0
  20. data/lib/reactive_freight/carriers/saia.rb +336 -0
  21. data/lib/reactive_freight/carriers/sefl.rb +234 -0
  22. data/lib/reactive_freight/carriers/totl.rb +96 -0
  23. data/lib/reactive_freight/carriers/wrds.rb +218 -0
  24. data/lib/reactive_freight/configuration/carriers/btvp.yml +139 -0
  25. data/lib/reactive_freight/configuration/carriers/clni.yml +107 -0
  26. data/lib/reactive_freight/configuration/carriers/ctbv.yml +117 -0
  27. data/lib/reactive_freight/configuration/carriers/dphe.yml +124 -0
  28. data/lib/reactive_freight/configuration/carriers/drrq.yml +115 -0
  29. data/lib/reactive_freight/configuration/carriers/fcsy.yml +104 -0
  30. data/lib/reactive_freight/configuration/carriers/fwda.yml +117 -0
  31. data/lib/reactive_freight/configuration/carriers/jfj_transportation.yml +2 -0
  32. data/lib/reactive_freight/configuration/carriers/pens.yml +22 -0
  33. data/lib/reactive_freight/configuration/carriers/rdfs.yml +135 -0
  34. data/lib/reactive_freight/configuration/carriers/saia.yml +117 -0
  35. data/lib/reactive_freight/configuration/carriers/sefl.yml +115 -0
  36. data/lib/reactive_freight/configuration/carriers/totl.yml +107 -0
  37. data/lib/reactive_freight/configuration/carriers/wrds.yml +19 -0
  38. data/lib/reactive_freight/configuration/platforms/carrier_logistics.yml +25 -0
  39. data/lib/reactive_freight/configuration/platforms/liftoff.yml +12 -0
  40. data/lib/reactive_freight/package.rb +137 -0
  41. data/lib/reactive_freight/platform.rb +36 -0
  42. data/lib/reactive_freight/platforms.rb +4 -0
  43. data/lib/reactive_freight/platforms/carrier_logistics.rb +317 -0
  44. data/lib/reactive_freight/platforms/liftoff.rb +102 -0
  45. data/lib/reactive_freight/rate_estimate.rb +113 -0
  46. data/lib/reactive_freight/shipment_event.rb +10 -0
  47. data/reactive_freight.gemspec +39 -0
  48. data/service_type_symbols.txt +4 -0
  49. metadata +198 -0
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class SAIA < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Saia'
9
+ @@scac = 'SAIA'
10
+
11
+ # Documents
12
+
13
+ # Rates
14
+ def find_rates(origin, destination, packages, options = {})
15
+ options = @options.merge(options)
16
+ origin = Location.from(origin)
17
+ destination = Location.from(destination)
18
+ packages = Array(packages)
19
+
20
+ request = build_rate_request(origin, destination, packages, options)
21
+ parse_rate_response(origin, destination, commit_soap(:rates, request))
22
+ end
23
+
24
+ # Tracking
25
+ def find_tracking_info(tracking_number)
26
+ request = build_tracking_request(tracking_number)
27
+ parse_tracking_response(commit_soap(:track, request))
28
+ end
29
+
30
+ protected
31
+
32
+ def commit_soap(action, request)
33
+ Savon.client(
34
+ wsdl: request_url(action),
35
+ convert_request_keys_to: :none,
36
+ env_namespace: :soap,
37
+ element_form_default: :qualified
38
+ ).call(
39
+ @conf.dig(:api, :actions, action),
40
+ message: request
41
+ ).body.to_hash
42
+ end
43
+
44
+ def request_url(action)
45
+ scheme = @conf.dig(:api, :use_ssl) ? 'https://' : 'http://'
46
+ "#{scheme}#{@conf.dig(:api, :domain)}#{@conf.dig(:api, :endpoints, action)}"
47
+ end
48
+
49
+ # Documents
50
+
51
+ # Rates
52
+ def build_rate_request(origin, destination, packages, options = {})
53
+ options = @options.merge(options)
54
+
55
+ accessorials = []
56
+ unless options[:accessorials].blank?
57
+ serviceable_accessorials?(options[:accessorials])
58
+ options[:accessorials].each do |a|
59
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
60
+ accessorials << { 'AccessorialItem': { 'Code': @conf.dig(:accessorials, :mappable)[a] } }
61
+ end
62
+ end
63
+ end
64
+
65
+ excessive_length_total_inches = 0
66
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
67
+ if longest_dimension >= 96
68
+ accessorials << { 'AccessorialItem': { 'Code': 'ExcessiveLength' } }
69
+ excessive_length_total_inches += longest_dimension
70
+ end
71
+ excessive_length_total_inches = excessive_length_total_inches.ceil.to_s
72
+
73
+ accessorials = accessorials.uniq
74
+
75
+ details = []
76
+ dimensions = []
77
+ packages.each do |package|
78
+ details << {
79
+ 'DetailItem': {
80
+ 'Weight': package.pounds.ceil,
81
+ 'Class': package.freight_class.to_s,
82
+ 'Length': package.length(:in).ceil,
83
+ 'Width': package.width(:in).ceil,
84
+ 'Height': package.height(:in).ceil
85
+ }
86
+ }
87
+ dimensions << {
88
+ 'DimensionItem': {
89
+ 'Units': 1,
90
+ 'Length': package.length(:in).round(2),
91
+ 'Width': package.width(:in).round(2),
92
+ 'Height': package.height(:in).round(2),
93
+ 'Type': 'IN' # inches
94
+ }
95
+ }
96
+ end
97
+ request = {
98
+ 'request': {
99
+ 'Application': 'ThirdParty',
100
+ 'AccountNumber': options[:account],
101
+ 'UserID': options[:username],
102
+ 'Password': options[:password],
103
+ 'TestMode': options[:debug].blank? ? 'N' : 'Y',
104
+ 'BillingTerms': 'Prepaid',
105
+ 'OriginCity': origin.city,
106
+ 'OriginState': origin.state,
107
+ 'OriginZipcode': origin.to_hash[:postal_code].to_s.upcase,
108
+ 'DestinationCity': destination.city,
109
+ 'DestinationState': destination.state,
110
+ 'DestinationZipcode': destination.to_hash[:postal_code].to_s.upcase,
111
+ 'WeightUnits': 'LBS',
112
+ 'TotalCube': packages.inject(0) { |_sum, p| _sum += p.cubic_ft }.to_f.round(2),
113
+ 'TotalCubeUnits': 'CUFT', # cubic ft
114
+ 'ExcessiveLengthTotalInches': excessive_length_total_inches,
115
+ 'Details': details,
116
+ 'Dimensions': dimensions,
117
+ 'Accessorials': accessorials
118
+ }
119
+ }
120
+
121
+ save_request(request)
122
+ request
123
+ end
124
+
125
+ def parse_rate_response(origin, destination, response)
126
+ success = true
127
+ message = ''
128
+
129
+ if !response
130
+ success = false
131
+ message = 'API Error: Unknown response'
132
+ else
133
+ error = response.dig(:create_response, :create_result, :code)
134
+
135
+ if !error.blank?
136
+ success = false
137
+ message = response.dig(:create_response, :create_result, :message)
138
+ else
139
+ response = response.dig(:create_response, :create_result)
140
+ cost = response.dig(:total_invoice)
141
+ if cost
142
+ cost = cost.sub('.', '').to_i
143
+ transit_days = response.dig(:standard_service_days).to_i
144
+ estimate_reference = response.dig(:quote_number)
145
+
146
+ rate_estimates = []
147
+ rate_estimates << RateEstimate.new(
148
+ origin,
149
+ destination,
150
+ { scac: self.class.scac.upcase, name: self.class.name },
151
+ :standard,
152
+ transit_days: transit_days,
153
+ estimate_reference: estimate_reference,
154
+ total_cost: cost,
155
+ total_price: cost,
156
+ currency: 'USD',
157
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
158
+ )
159
+
160
+ [
161
+ { guaranteed_ltl: response.dig(:guarantee_amount) },
162
+ { guaranteed_ltl_am: response.dig(:guarantee_amount12pm) },
163
+ { guaranteed_ltl_pm: response.dig(:guarantee_amount2pm) }
164
+ ].each do |service|
165
+ if !service.values[0] == '0' && !service.values[0].blank?
166
+ cost = service.values[0].sub('.', '').to_i
167
+ rate_estimates << RateEstimate.new(
168
+ origin,
169
+ destination,
170
+ { scac: self.class.scac.upcase, name: self.class.name },
171
+ service.keys[0],
172
+ delivery_range: delivery_range,
173
+ estimate_reference: estimate_reference,
174
+ total_cost: cost,
175
+ total_price: cost,
176
+ currency: 'USD',
177
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
178
+ )
179
+ end
180
+ rate_estimates
181
+ end
182
+ else
183
+ success = false
184
+ message = 'API Error: Cost is emtpy'
185
+ end
186
+ end
187
+ end
188
+
189
+ RateResponse.new(
190
+ success,
191
+ message,
192
+ response.to_hash,
193
+ rates: rate_estimates,
194
+ response: response,
195
+ request: last_request
196
+ )
197
+ end
198
+
199
+ # Tracking
200
+ def build_tracking_request(tracking_number)
201
+ request = { pro_number: tracking_number }
202
+ save_request(request)
203
+ request
204
+ end
205
+
206
+ def parse_city_state(str)
207
+ return nil if str.blank?
208
+
209
+ Location.new(
210
+ city: str.split(', ')[0].titleize,
211
+ state: str.split(', ')[1].split(' ')[0].upcase,
212
+ country: ActiveUtils::Country.find('USA')
213
+ )
214
+ end
215
+
216
+ def parse_city(str)
217
+ return nil if str.blank?
218
+
219
+ Location.new(
220
+ city: str.squeeze.strip.titleize,
221
+ state: nil,
222
+ country: ActiveUtils::Country.find('USA')
223
+ )
224
+ end
225
+
226
+ def parse_date(date)
227
+ date ? DateTime.strptime(date, '%m/%d/%Y %l:%M:%S %p').to_s(:db) : nil
228
+ end
229
+
230
+ def parse_location(comment, delimiters)
231
+ city = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[0].titleize
232
+ state = comment.split(delimiters[0])[0].split(delimiters[1])[1].split(', ')[1].upcase
233
+
234
+ Location.new(
235
+ city: city,
236
+ province: state,
237
+ state: state,
238
+ country: ActiveUtils::Country.find('USA')
239
+ )
240
+ end
241
+
242
+ def parse_tracking_response(response)
243
+ unless response.dig(:get_tracking_response, :get_tracking_result, :tracking_status_response)
244
+ status = json.dig('error') || "API Error: HTTP #{response.status[0]}"
245
+ return TrackingResponse.new(false, status, json, carrier: "#{@@scac}, #{@@name}", json: json, response: response, request: last_request)
246
+ end
247
+
248
+ search_result = response.dig(:get_tracking_response, :get_tracking_result)
249
+
250
+ shipper_address = Location.new(
251
+ street: search_result.dig(:shipperaddress).squeeze.strip.titleize,
252
+ city: search_result.dig(:shipper_city).squeeze.strip.titleize,
253
+ state: search_result.dig(:shipper_state).strip.upcase,
254
+ postal_code: search_result.dig(:shipper_zip).strip,
255
+ country: ActiveUtils::Country.find('USA')
256
+ )
257
+
258
+ receiver_address = Location.new(
259
+ street: search_result.dig(:consaddress).squeeze.strip.titleize,
260
+ city: search_result.dig(:cons_city).squeeze.strip.titleize,
261
+ state: search_result.dig(:cons_state).strip.upcase,
262
+ postal_code: search_result.dig(:cons_zip).strip,
263
+ country: ActiveUtils::Country.find('USA')
264
+ )
265
+
266
+ actual_delivery_date = parse_date(search_result.dig('Shipment', 'DeliveredDateTime'))
267
+ pickup_date = parse_date(search_result.dig(:pickup_date))
268
+ scheduled_delivery_date = parse_date(search_result.dig('Shipment', 'ApptDateTime'))
269
+ tracking_number = search_result.dig('Shipment', 'SearchItem')
270
+
271
+ shipment_events = []
272
+ shipment_events << ShipmentEvent.new(
273
+ :picked_up,
274
+ pickup_date,
275
+ shipper_address
276
+ )
277
+
278
+ api_events = search_result.dig(:tracking_status_response, :tracking_status_row).reverse
279
+ api_events.each do |api_event|
280
+ event_key = nil
281
+ comment = api_event.dig(:tracking_status)
282
+
283
+ @conf.dig(:events, :types).each do |key, val|
284
+ if comment.downcase.include?(val)
285
+ event_key = key
286
+ break
287
+ end
288
+ end
289
+ next if event_key.blank?
290
+
291
+ case event_key
292
+ when :arrived_at_terminal
293
+ location = parse_city(comment.split('arrived')[1].upcase.split('SERVICE CENTER')[0])
294
+ when :delivered
295
+ location = parse_city_state(comment.split('in ')[1].split('completed')[0])
296
+ when :departed
297
+ location = parse_city(comment.split('departed')[1].upcase.split('SERVICE CENTER')[0])
298
+ when :out_for_delivery
299
+ location = receiver_address
300
+ when :trailer_closed
301
+ location = parse_city(comment.split('Location:')[1])
302
+ when :trailer_unloaded
303
+ location = parse_city(comment.split('Location:')[1])
304
+ end
305
+
306
+ datetime_without_time_zone = parse_date(api_event.dig(:tracking_date))
307
+
308
+ # status and type_code set automatically by ActiveFreight based on event
309
+ shipment_events << ShipmentEvent.new(event_key, datetime_without_time_zone, location)
310
+ end
311
+
312
+ shipment_events = shipment_events.sort_by(&:time)
313
+
314
+ TrackingResponse.new(
315
+ true,
316
+ shipment_events.last.status,
317
+ response,
318
+ carrier: "#{@@scac}, #{@@name}",
319
+ hash: response,
320
+ response: response,
321
+ status: status,
322
+ type_code: shipment_events.last.status,
323
+ ship_time: parse_date(search_result.dig('Shipment', 'ProDateTime')),
324
+ scheduled_delivery_date: scheduled_delivery_date,
325
+ actual_delivery_date: actual_delivery_date,
326
+ delivery_signature: nil,
327
+ shipment_events: shipment_events,
328
+ shipper_address: shipper_address,
329
+ origin: shipper_address,
330
+ destination: receiver_address,
331
+ tracking_number: tracking_number,
332
+ request: last_request
333
+ )
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveShipping
4
+ class SEFL < ReactiveShipping::Carrier
5
+ REACTIVE_FREIGHT_CARRIER = true
6
+
7
+ cattr_reader :name, :scac
8
+ @@name = 'Southeastern Freight Lines'
9
+ @@scac = 'SEFL'
10
+
11
+ JSON_HEADERS = {
12
+ 'Accept': 'application/json',
13
+ 'charset': 'utf-8',
14
+ 'Content-Type' => 'application/x-www-form-urlencoded'
15
+ }.freeze
16
+
17
+ # Documents
18
+
19
+ # Rates
20
+ def find_rates(origin, destination, packages, options = {})
21
+ options = @options.merge(options)
22
+ origin = Location.from(origin)
23
+ destination = Location.from(destination)
24
+ packages = Array(packages)
25
+
26
+ request = build_rate_request(origin, destination, packages, options)
27
+ parse_rate_response(origin, destination, commit(request))
28
+ end
29
+
30
+ # Tracking
31
+
32
+ protected
33
+
34
+ def build_url(action, options = {})
35
+ options = @options.merge(options)
36
+ "#{base_url}#{@conf.dig(:api, :endpoints, action)}"
37
+ end
38
+
39
+ def base_url
40
+ "https://#{@conf.dig(:api, :domain)}"
41
+ end
42
+
43
+ def auth_header(options = {})
44
+ options = @options.merge(options)
45
+ if !options[:username].blank? && !options[:password].blank?
46
+ auth = Base64.strict_encode64("#{options[:username]}:#{options[:password]}")
47
+ return { 'Authorization': "Basic #{auth}" }
48
+ end
49
+
50
+ {}
51
+ end
52
+
53
+ def build_request(action, options = {})
54
+ options = @options.merge(options)
55
+ headers = JSON_HEADERS
56
+ headers = headers.merge(auth_header)
57
+ headers = headers.merge(options[:headers]) unless options[:headers].blank?
58
+ body = URI.encode_www_form(options[:body]) unless options[:body].blank?
59
+
60
+ request = {
61
+ url: options[:url].blank? ? build_url(action, options) : options[:url],
62
+ headers: headers,
63
+ method: @conf.dig(:api, :methods, action),
64
+ body: body
65
+ }
66
+
67
+ save_request(request)
68
+ request
69
+ end
70
+
71
+ def commit(request)
72
+ url = request[:url]
73
+ headers = request[:headers]
74
+ method = request[:method]
75
+ body = request[:body]
76
+
77
+ response = case method
78
+ when :post
79
+ HTTParty.post(url, headers: headers, body: body)
80
+ else
81
+ HTTParty.get(url, headers: headers)
82
+ end
83
+
84
+ JSON.parse(response.body) if response&.body
85
+ end
86
+
87
+ # Documents
88
+
89
+ # Rates
90
+ def build_rate_request(origin, destination, packages, options = {})
91
+ options = @options.merge(options)
92
+
93
+ accessorials = []
94
+ unless options[:accessorials].blank?
95
+ serviceable_accessorials?(options[:accessorials])
96
+ options[:accessorials].each do |a|
97
+ unless @conf.dig(:accessorials, :unserviceable).include?(a)
98
+ accessorials << @conf.dig(:accessorials, :mappable)[a]
99
+ end
100
+ end
101
+ end
102
+
103
+ longest_dimension = packages.inject([]) { |_arr, p| [p.length(:in), p.width(:in)] }.max.ceil
104
+ accessorials << 'chkOD' if longest_dimension >= 96
105
+
106
+ accessorials = accessorials.uniq
107
+
108
+ pickup_on = options[:pickup_on].blank? ? Date.current : options[:pickup_on]
109
+
110
+ body = {
111
+ returnX: 'Y',
112
+ rateXML: 'Y',
113
+ CustomerAccount: options[:account].to_i.to_s.rjust(9, '0'),
114
+ CustomerName: options[:customer_name],
115
+ CustomerStreet: options.dig(:customer_address, :street),
116
+ CustomerCity: options.dig(:customer_address, :city),
117
+ CustomerState: options.dig(:customer_address, :state),
118
+ CustomerZip: options.dig(:customer_address, :zip_code),
119
+ Description: 'Freight All Kinds',
120
+ Option: 'T',
121
+ Terms: 'P',
122
+ allowSpot: packages.inject(0) { |_sum, p| _sum += [p.length(:in), p.width(:in)].max.ceil } >= 120 ? 'Y' : 'N',
123
+ DimsOption: 'I',
124
+ EmailAddress: options[:customer_email].blank? ? 'unknown@fake.fake' : options[:customer_email],
125
+ PickupMonth: pickup_on.strftime('%_m'),
126
+ PickupDay: pickup_on.strftime('%_d'),
127
+ PickupYear: pickup_on.strftime('%Y'),
128
+ OriginCity: origin.to_hash[:city],
129
+ OriginState: origin.to_hash[:province],
130
+ OriginZip: origin.to_hash[:postal_code],
131
+ OrigCountry: 'U',
132
+ DestinationCity: destination.to_hash[:city],
133
+ DestinationState: destination.to_hash[:province],
134
+ DestinationZip: destination.to_hash[:postal_code],
135
+ DestCountry: 'U'
136
+ }
137
+
138
+ if longest_dimension >= 96
139
+ body = body.deep_merge(
140
+ {
141
+ ODLength: longest_dimension,
142
+ ODLengthUnit: 'I'
143
+ }
144
+ )
145
+ end
146
+
147
+ i = 0
148
+ packages.each do |package|
149
+ i += 1
150
+ body = body.deep_merge({ "Class#{i}": package.freight_class.to_s.sub('.', '').to_i })
151
+ body = body.deep_merge({ "CubicFt#{i}": package.cubic_ft }) if destination.to_hash[:province].upcase == 'PR'
152
+ body = body.deep_merge({ "Description#{i}": 'Freight All Kinds' })
153
+ body = body.deep_merge({ "PieceLength#{i}": package.length(:in).ceil })
154
+ body = body.deep_merge({ "PieceWidth#{i}": package.width(:in).ceil })
155
+ body = body.deep_merge({ "PieceHeight#{i}": package.height(:in).ceil })
156
+ body = body.deep_merge({ "Weight#{i}": package.pounds.ceil })
157
+ end
158
+
159
+ unless accessorials.blank?
160
+ accessorials.each do |_accessorial|
161
+ body = body.deep_merge({ accessorial: 'on' })
162
+ end
163
+ end
164
+
165
+ request = build_request(:rates, body: body)
166
+
167
+ save_request(request)
168
+ request
169
+ end
170
+
171
+ def parse_rate_response(origin, destination, response)
172
+ success = true
173
+ message = ''
174
+
175
+ if !response
176
+ success = false
177
+ message = 'API Error: Unknown response'
178
+ elsif !response.dig('errorMessage').blank?
179
+ success = false
180
+ message = response.dig('errorMessage')
181
+ else
182
+ sleep(5) # TODO: Maybe improve this?
183
+ url = response.dig('detailQuoteLocation').gsub('\\', '')
184
+ request = build_request(:get_rate, url: url)
185
+ save_request(request)
186
+ response = commit(request)
187
+
188
+ if !response
189
+ success = false
190
+ message = 'API Error: Unknown response'
191
+ elsif !response.dig('errorMessage').blank?
192
+ success = false
193
+ message = response.dig('errorMessage')
194
+ else
195
+ cost = response.dig('rateQuote')
196
+ if cost
197
+ cost = cost.sub('.', '').to_i
198
+ estimate_reference = response.dig('quoteNumber')
199
+ transit_days = response.dig('transitTime').to_i
200
+
201
+ rate_estimates = [
202
+ RateEstimate.new(
203
+ origin,
204
+ destination,
205
+ { scac: self.class.scac.upcase, name: self.class.name },
206
+ :standard,
207
+ transit_days: transit_days,
208
+ estimate_reference: estimate_reference,
209
+ total_cost: cost,
210
+ total_price: cost,
211
+ currency: 'USD',
212
+ with_excessive_length_fees: @conf.dig(:attributes, :rates, :with_excessive_length_fees)
213
+ )
214
+ ]
215
+ else
216
+ success = false
217
+ message = 'API Error: Cost is emtpy'
218
+ end
219
+ end
220
+ end
221
+
222
+ RateResponse.new(
223
+ success,
224
+ message,
225
+ response.to_hash,
226
+ rates: rate_estimates,
227
+ response: response,
228
+ request: last_request
229
+ )
230
+ end
231
+
232
+ # Tracking
233
+ end
234
+ end