pbshipping 1.0.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 +7 -0
- data/LICENSE +20 -0
- data/README.md +185 -0
- data/lib/pbshipping.rb +173 -0
- data/lib/pbshipping/account.rb +45 -0
- data/lib/pbshipping/address.rb +55 -0
- data/lib/pbshipping/api_object.rb +131 -0
- data/lib/pbshipping/api_resource.rb +22 -0
- data/lib/pbshipping/authentication.rb +36 -0
- data/lib/pbshipping/carrier.rb +53 -0
- data/lib/pbshipping/country.rb +22 -0
- data/lib/pbshipping/customs.rb +26 -0
- data/lib/pbshipping/developer.rb +120 -0
- data/lib/pbshipping/error.rb +73 -0
- data/lib/pbshipping/manifest.rb +130 -0
- data/lib/pbshipping/merchant.rb +22 -0
- data/lib/pbshipping/parcel.rb +26 -0
- data/lib/pbshipping/rate.rb +32 -0
- data/lib/pbshipping/scandetails.rb +22 -0
- data/lib/pbshipping/shipment.rb +185 -0
- data/lib/pbshipping/shipping_api_resource.rb +26 -0
- data/lib/pbshipping/tracking.rb +53 -0
- data/lib/pbshipping/transactiondetails.rb +22 -0
- data/lib/pbshipping/version.rb +3 -0
- data/test/tc_address.rb +46 -0
- data/test/tc_manifest.rb +69 -0
- data/test/tc_merchant.rb +44 -0
- data/test/tc_shipment.rb +89 -0
- data/test/tc_transactionreport.rb +93 -0
- data/test/test_util.rb +222 -0
- data/test/ts_all_tests.rb +21 -0
- data/tutorial.rb +464 -0
- metadata +96 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2016 Pitney Bowes Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the MIT License (the "License"); you may not use this file
|
5
|
+
# except in compliance with the License. You may obtain a copy of the License
|
6
|
+
# in the LICENSE file or at
|
7
|
+
# https://opensource.org/licenses/MIT
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
11
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
#
|
15
|
+
# File: ts_all_tests.rb
|
16
|
+
# Description: running all available unit tests
|
17
|
+
#
|
18
|
+
|
19
|
+
require "test/unit"
|
20
|
+
Dir["./tc_*.rb"].each {|file| require file }
|
21
|
+
|
data/tutorial.rb
ADDED
@@ -0,0 +1,464 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2016 Pitney Bowes Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the MIT License (the "License"); you may not use this file
|
5
|
+
# except in compliance with the License. You may obtain a copy of the License
|
6
|
+
# in the LICENSE file or at
|
7
|
+
# https://opensource.org/licenses/MIT
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
11
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
#
|
15
|
+
# File: tutorial.rb
|
16
|
+
# Description: a tutorial example exercising the shipping apis
|
17
|
+
#
|
18
|
+
|
19
|
+
# Tutorial
|
20
|
+
require 'optparse'
|
21
|
+
require "json"
|
22
|
+
require 'pbshipping'
|
23
|
+
|
24
|
+
module PBShippingTutorial
|
25
|
+
|
26
|
+
@api_key = nil
|
27
|
+
@api_secret = nil
|
28
|
+
@dev_id = nil
|
29
|
+
@merchant_email = nil
|
30
|
+
@merchant = nil
|
31
|
+
@from_addr = nil
|
32
|
+
@to_addr = nil
|
33
|
+
@shipment = nil
|
34
|
+
@shipment_orig_tx_id = nil
|
35
|
+
@tracking = nil
|
36
|
+
@manifest = nil
|
37
|
+
@manifest_orig_tx_id = nil
|
38
|
+
@auth_obj = nil
|
39
|
+
|
40
|
+
@my_bulk_merchant_addr = {
|
41
|
+
:addressLines => ["27 Waterview Drive"],
|
42
|
+
:cityTown => "Shelton",
|
43
|
+
:stateProvince => "Connecticut",
|
44
|
+
:postalCode => "06484",
|
45
|
+
:countryCode => "US",
|
46
|
+
:company => "Pitney Bowes",
|
47
|
+
:name => "John Doe",
|
48
|
+
:email => "dummy@pbshipping.com",
|
49
|
+
:phone => "203-792-1600",
|
50
|
+
:residential => false
|
51
|
+
}
|
52
|
+
|
53
|
+
@my_origin_addr = {
|
54
|
+
:addressLines => ["37 Executive Drive"],
|
55
|
+
:cityTown => "Danbury",
|
56
|
+
:stateProvince => "Connecticut",
|
57
|
+
:postalCode => "06810",
|
58
|
+
:countryCode => "US"
|
59
|
+
}
|
60
|
+
|
61
|
+
@my_dest_addr = {
|
62
|
+
:addressLines => ["27 Waterview Drive"],
|
63
|
+
:cityTown => "Shelton",
|
64
|
+
:stateProvince => "Connecticut",
|
65
|
+
:postalCode => "06484",
|
66
|
+
:countryCode => "US"
|
67
|
+
}
|
68
|
+
|
69
|
+
@my_parcel = {
|
70
|
+
:weight => {
|
71
|
+
:unitOfMeasurement => "OZ",
|
72
|
+
:weight => 1
|
73
|
+
},
|
74
|
+
:dimension => {
|
75
|
+
:unitOfMeasurement => "IN",
|
76
|
+
:length => 6,
|
77
|
+
:width => 0.25,
|
78
|
+
:height => 4,
|
79
|
+
:irregularParcelGirth => 0.002
|
80
|
+
}
|
81
|
+
}
|
82
|
+
|
83
|
+
@my_rate_request_carrier_usps = {
|
84
|
+
:carrier => "usps",
|
85
|
+
:serviceId => "PM",
|
86
|
+
:parcelType => "PKG",
|
87
|
+
:specialServices => [
|
88
|
+
{
|
89
|
+
:specialServiceId => "Ins",
|
90
|
+
:inputParameters => [
|
91
|
+
{
|
92
|
+
:name => "INPUT_VALUE",
|
93
|
+
:value => "50"
|
94
|
+
}
|
95
|
+
]
|
96
|
+
},
|
97
|
+
{
|
98
|
+
:specialServiceId => "DelCon",
|
99
|
+
:inputParameters => [
|
100
|
+
{
|
101
|
+
:name => "INPUT_VALUE",
|
102
|
+
:value => "0"
|
103
|
+
}
|
104
|
+
]
|
105
|
+
}
|
106
|
+
],
|
107
|
+
:inductionPostalCode => "06810"
|
108
|
+
}
|
109
|
+
|
110
|
+
@my_shipment_document = {
|
111
|
+
:type => "SHIPPING_LABEL",
|
112
|
+
:contentType => "URL",
|
113
|
+
:size => "DOC_8X11",
|
114
|
+
:fileFormat => "PDF",
|
115
|
+
:printDialogOption => "NO_PRINT_DIALOG"
|
116
|
+
}
|
117
|
+
|
118
|
+
# use the current timestamp to generate a transaction id
|
119
|
+
def self.get_pb_tx_id()
|
120
|
+
|
121
|
+
Time.now().to_i.to_s
|
122
|
+
end
|
123
|
+
|
124
|
+
# obtain authentication, developer, and merchant infomration from
|
125
|
+
# command line
|
126
|
+
def self.initialize_info()
|
127
|
+
|
128
|
+
options = {}
|
129
|
+
|
130
|
+
# try environment variables ...
|
131
|
+
if ENV["PBSHIPPING_KEY"] != nil
|
132
|
+
options[:key] = ENV["PBSHIPPING_KEY"]
|
133
|
+
end
|
134
|
+
if ENV["PBSHIPPING_SECRET"] != nil
|
135
|
+
options[:secret] = ENV["PBSHIPPING_SECRET"]
|
136
|
+
end
|
137
|
+
if ENV["PBSHIPPING_DEVID"] != nil
|
138
|
+
options[:devid] = ENV["PBSHIPPING_DEVID"]
|
139
|
+
end
|
140
|
+
if ENV["PBSHIPPING_MERCHANT"] != nil
|
141
|
+
options[:merchant] = ENV["PBSHIPPING_MERCHANT"]
|
142
|
+
end
|
143
|
+
|
144
|
+
optparse = OptionParser.new do |opts|
|
145
|
+
opts.banner = "Usage: tutorial.rb [options]"
|
146
|
+
# command line arguments overwrite environment variables
|
147
|
+
opts.on('-h', '--help', 'Display help') do
|
148
|
+
puts opts
|
149
|
+
exit
|
150
|
+
end
|
151
|
+
opts.on('-k', '--key API_KEY', 'API key for authentication') do |api_key|
|
152
|
+
options[:key] = api_key
|
153
|
+
end
|
154
|
+
opts.on('-s', '--secret API_SECRET', 'API secret for authentication') do |api_secret|
|
155
|
+
options[:secret] = api_secret
|
156
|
+
end
|
157
|
+
opts.on('-d', '--devid DEVELOPER_ID', 'Pitney Bowes Developer ID') do |dev_id|
|
158
|
+
options[:devid] = dev_id
|
159
|
+
end
|
160
|
+
opts.on('-m', '--merchant MERCHANT_EMAIL', 'Merchant email') do |merchant_email|
|
161
|
+
options[:merchant] = merchant_email
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
begin
|
166
|
+
optparse.parse!
|
167
|
+
mandatory = [:key, :secret, :devid, :merchant]
|
168
|
+
missing = mandatory.select{ |param| options[param].nil? }
|
169
|
+
unless missing.empty?
|
170
|
+
puts "Missing options: #{missing.join(', ')}"
|
171
|
+
puts optparse
|
172
|
+
exit
|
173
|
+
end
|
174
|
+
@api_key = options[:key]
|
175
|
+
@api_secret = options[:secret]
|
176
|
+
@dev_id = options[:devid]
|
177
|
+
@merchant_email = options[:merchant]
|
178
|
+
rescue OptionParser::InvalidOption, OptionParser::MissingArgument
|
179
|
+
puts $!.to_s
|
180
|
+
puts optparse
|
181
|
+
exit
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# choose sandbox or production pitney bowes shipping api server
|
186
|
+
def self.choose_environment()
|
187
|
+
|
188
|
+
puts "Choose the sandbox environment ..."
|
189
|
+
PBShipping::configuration[:is_production] = false
|
190
|
+
end
|
191
|
+
|
192
|
+
# authenticate and obtain the authentication object for subsequent use
|
193
|
+
# underlying API: POST /oauth/token
|
194
|
+
def self.authenticate()
|
195
|
+
|
196
|
+
puts 'Authenticating ...'
|
197
|
+
@auth_obj = PBShipping::AuthenticationToken.new(@api_key, @api_secret)
|
198
|
+
end
|
199
|
+
|
200
|
+
# return the list of supported countries
|
201
|
+
# underlying API: GET /countries
|
202
|
+
def self.check_carrier_supported_countries()
|
203
|
+
|
204
|
+
puts "Querying for supported countries of USPS carrier ..."
|
205
|
+
country_list = PBShipping::Carrier.getCountriesForCarrier(@auth_obj, "usps", "US")
|
206
|
+
n = country_list.length()
|
207
|
+
puts " number of supported countries is " + n.to_s
|
208
|
+
puts " one example is " + country_list[n/3].countryName
|
209
|
+
end
|
210
|
+
|
211
|
+
# managing merchant account under individual account mode
|
212
|
+
# underlying API: GET /developers/{developerId}/merchants/emails/{emailId}/
|
213
|
+
# GET /ledger/accounts/{accountNumber}/balance
|
214
|
+
def self.manage_individual_mode_merchant()
|
215
|
+
|
216
|
+
# querying for merchant information
|
217
|
+
puts "Managing merchant (individual account mode) ..."
|
218
|
+
@developer = PBShipping::Developer.new( { :developerId => @dev_id } )
|
219
|
+
@merchant = @developer.registerMerchantIndividualAccount(
|
220
|
+
@auth_obj, @merchant_email)
|
221
|
+
merchant_account_number = @merchant.paymentAccountNumber
|
222
|
+
|
223
|
+
# querying for merchant account balance
|
224
|
+
balance = PBShipping::Account.getBalanceByAccountNumber(
|
225
|
+
@auth_obj, merchant_account_number)
|
226
|
+
puts " merchant full name is " + @merchant.fullName
|
227
|
+
puts " shipper id is " + @merchant.postalReportingNumber
|
228
|
+
puts " payment account number is " + merchant_account_number
|
229
|
+
puts " current balance is " + balance.currencyCode + " " + \
|
230
|
+
balance.balance.to_s
|
231
|
+
end
|
232
|
+
|
233
|
+
# managing merchant account under bulk account mode
|
234
|
+
# underlying API: POST /developers/{developerId}/merchants/registration
|
235
|
+
def self.manage_bulk_mode_merchant()
|
236
|
+
|
237
|
+
puts "Managing merchant (bulk account mode) ..."
|
238
|
+
@developer = PBShipping::Developer.new( { :developerId => @dev_id } )
|
239
|
+
merchant_addr = PBShipping::Address.new(@my_bulk_merchant_addr)
|
240
|
+
merchant = @developer.registerMerchantBulkAccount(@auth_obj, merchant_addr)
|
241
|
+
puts merchant
|
242
|
+
end
|
243
|
+
|
244
|
+
# verifying addresses
|
245
|
+
# underlying API: POST /addresses/verify
|
246
|
+
def self.verify_addresses()
|
247
|
+
|
248
|
+
puts "Verifying origin and destination addresses ... "
|
249
|
+
@from_addr = PBShipping::Address.new(@my_origin_addr)
|
250
|
+
@from_addr.verify(@auth_obj, false)
|
251
|
+
if @from_addr.status.downcase == "validated_changed"
|
252
|
+
puts " origin address cleansed, addressLine is " + \
|
253
|
+
@from_addr.addressLines[0]
|
254
|
+
end
|
255
|
+
|
256
|
+
@to_addr = PBShipping::Address.new(@my_dest_addr)
|
257
|
+
@to_addr.verify(@auth_obj, false)
|
258
|
+
if @to_addr.status.downcase == "validated_changed"
|
259
|
+
puts " destination address cleansed, addressLine is " + \
|
260
|
+
@to_addr.addressLines[0]
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# querying rates to prepare a shipment
|
265
|
+
# underlying API: POST /rates
|
266
|
+
def self.prepare_shipment()
|
267
|
+
|
268
|
+
puts "Preparing shipment and checking for shipment rates ..."
|
269
|
+
rates = [ PBShipping::Rate.new(@my_rate_request_carrier_usps) ]
|
270
|
+
parcel = PBShipping::Parcel.new(@my_parcel)
|
271
|
+
documents = [ PBShipping::Document.new(@my_shipment_document)]
|
272
|
+
shipmentOptions = [
|
273
|
+
PBShipping::ShipmentOptions.new({
|
274
|
+
:name => "SHIPPER_ID",
|
275
|
+
:value => @merchant.postalReportingNumber
|
276
|
+
}),
|
277
|
+
PBShipping::ShipmentOptions.new({
|
278
|
+
:name => "ADD_TO_MANIFEST",
|
279
|
+
:value => true
|
280
|
+
})
|
281
|
+
]
|
282
|
+
@shipment = PBShipping::Shipment.new({
|
283
|
+
:fromAddress => @from_addr,
|
284
|
+
:toAddress => @to_addr,
|
285
|
+
:parcel => parcel,
|
286
|
+
:rates => rates,
|
287
|
+
:documents => documents,
|
288
|
+
:shipmentOptions => shipmentOptions
|
289
|
+
})
|
290
|
+
@shipment.rates = @shipment.getRates(@auth_obj, get_pb_tx_id(), true)
|
291
|
+
puts " total carrier charge: " + @shipment.rates[0][:totalCarrierCharge].to_s
|
292
|
+
end
|
293
|
+
|
294
|
+
# submit a shipment creation request and purchase a shipment label
|
295
|
+
# underlying API: POST /shipments
|
296
|
+
def self.create_and_purchase_shipment()
|
297
|
+
|
298
|
+
puts "Creating shipment and purchasing label ..."
|
299
|
+
@shipment_orig_tx_id = get_pb_tx_id()
|
300
|
+
@shipment.createAndPurchase(@auth_obj, @shipment_orig_tx_id, true)
|
301
|
+
|
302
|
+
puts " parcel tracking number is " + @shipment.parcelTrackingNumber
|
303
|
+
for doc in @shipment.documents
|
304
|
+
puts " document type is " + doc[:type]
|
305
|
+
if doc[:contentType] == "URL" && doc.key?(:contents)
|
306
|
+
puts " document URL is " + doc[:contents]
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
# reprint a shipment label
|
312
|
+
# underlying API: GET /shipments/{shipmentId}
|
313
|
+
def self.reprint_shipment()
|
314
|
+
|
315
|
+
puts "Reprinting label ..."
|
316
|
+
@shipment.reprintLabel(@auth_obj)
|
317
|
+
for doc in @shipment.documents
|
318
|
+
if doc[:contentType] == "URL" && doc.key?(:contents)
|
319
|
+
puts " document URL is " + doc[:contents]
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# retry a shipment purchase request
|
325
|
+
# underlying API: GET /shipments?originalTransactionId
|
326
|
+
def self.retry_shipment()
|
327
|
+
|
328
|
+
puts "Retrying shipment order ..."
|
329
|
+
@shipment.retry(@auth_obj, get_pb_tx_id(), @shipment_orig_tx_id)
|
330
|
+
end
|
331
|
+
|
332
|
+
# submit a shipment cancellation request
|
333
|
+
# underlying API: DELETE /shipments/{shipmentId}
|
334
|
+
def self.cancel_shipment()
|
335
|
+
|
336
|
+
puts "Canceling shipment order ..."
|
337
|
+
cancel_result = @shipment.cancel(@auth_obj, @shipment_orig_tx_id,
|
338
|
+
@shipment.rates[0].carrier)
|
339
|
+
puts " status: " + cancel_result["status"]
|
340
|
+
end
|
341
|
+
|
342
|
+
# create a manifest
|
343
|
+
# underlying API: POST /manifests
|
344
|
+
def self.create_manifest()
|
345
|
+
|
346
|
+
puts "Creating manifest ..."
|
347
|
+
@manifest = PBShipping::Manifest.new( {
|
348
|
+
:carrier => @tracking.carrier,
|
349
|
+
:submissionDate => Time.now.utc.strftime("%Y-%m-%d"),
|
350
|
+
:parcelTrackingNumbers => [@shipment.parcelTrackingNumber],
|
351
|
+
:fromAddress => @shipment.fromAddress
|
352
|
+
} )
|
353
|
+
@manifest_orig_tx_id = get_pb_tx_id()
|
354
|
+
@manifest.create(@auth_obj, @manifest_orig_tx_id)
|
355
|
+
puts " manifest tracking number is " + @manifest.manifestTrackingNumber
|
356
|
+
puts " manifest id is " + @manifest.manifestId
|
357
|
+
end
|
358
|
+
|
359
|
+
# reputs a manifest
|
360
|
+
# underlying API: GET /manifests/{manifestId}
|
361
|
+
def self.reprint_manifest()
|
362
|
+
|
363
|
+
puts "Repringing manifest ..."
|
364
|
+
|
365
|
+
@manifest.reprint(@auth_obj)
|
366
|
+
puts " reprinted manifestId is " + @manifest.manifestId
|
367
|
+
end
|
368
|
+
|
369
|
+
# retry a mainfest
|
370
|
+
# Underly API: GET /manifests
|
371
|
+
def self.retry_manifest()
|
372
|
+
|
373
|
+
puts "Retrying manifest request ..."
|
374
|
+
@manifest.retry(@auth_obj, get_pb_tx_id(), @manifest_orig_tx_id)
|
375
|
+
puts " manifest id is " + @manifest.manifestId
|
376
|
+
end
|
377
|
+
|
378
|
+
# get tracking information
|
379
|
+
# Underlying API: GET /tracking/{trackingNumber}
|
380
|
+
def self.get_tracking_update()
|
381
|
+
|
382
|
+
puts "Get tracking status ..."
|
383
|
+
@tracking = PBShipping::Tracking.new( { :trackingNumber => @shipment.parcelTrackingNumber } )
|
384
|
+
begin
|
385
|
+
@tracking.updateStatus(@auth_obj)
|
386
|
+
rescue => e
|
387
|
+
case e
|
388
|
+
when PBShipping::ApiError
|
389
|
+
if PBShipping::configuration[:is_production] == false
|
390
|
+
puts " no tracking information in sandbox environment"
|
391
|
+
return
|
392
|
+
end
|
393
|
+
raise e
|
394
|
+
end
|
395
|
+
end
|
396
|
+
puts " status = " + _tracking.status
|
397
|
+
end
|
398
|
+
|
399
|
+
# querying for a transaction report
|
400
|
+
# Underlying API: GET /
|
401
|
+
def self.get_transaction_report()
|
402
|
+
|
403
|
+
puts "Retrieving transaction report ..."
|
404
|
+
params = {}
|
405
|
+
params[:merchantId] = @merchant.postalReportingNumber
|
406
|
+
report = PBShipping::Developer.new({:developerId => @dev_id}).getTransactionReport(
|
407
|
+
@auth_obj, params)
|
408
|
+
puts " First few entries ..."
|
409
|
+
i = 0
|
410
|
+
for next_row in report.content
|
411
|
+
txn = PBShipping::TransactionDetails.new(next_row)
|
412
|
+
txn_detail = " id: " + txn.transactionId
|
413
|
+
txn_detail += " type: " + txn.transactionType
|
414
|
+
puts txn_detail
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
# navgiate through different steps in shipping workflow
|
419
|
+
def self.shipping_workflow()
|
420
|
+
|
421
|
+
begin
|
422
|
+
|
423
|
+
initialize_info()
|
424
|
+
|
425
|
+
choose_environment()
|
426
|
+
|
427
|
+
authenticate()
|
428
|
+
|
429
|
+
check_carrier_supported_countries()
|
430
|
+
|
431
|
+
# choose appropriate calls for individual or bulk account mode
|
432
|
+
manage_individual_mode_merchant()
|
433
|
+
#manage_bulk_mode_merchant()
|
434
|
+
|
435
|
+
verify_addresses()
|
436
|
+
|
437
|
+
prepare_shipment()
|
438
|
+
create_and_purchase_shipment()
|
439
|
+
reprint_shipment()
|
440
|
+
retry_shipment()
|
441
|
+
|
442
|
+
get_tracking_update()
|
443
|
+
|
444
|
+
create_manifest()
|
445
|
+
reprint_manifest()
|
446
|
+
retry_manifest()
|
447
|
+
|
448
|
+
get_transaction_report()
|
449
|
+
|
450
|
+
cancel_shipment()
|
451
|
+
|
452
|
+
rescue => e
|
453
|
+
if e.is_a?(PBShipping::ApiError) && e.error_info != nil
|
454
|
+
puts e.message
|
455
|
+
puts e.error_info
|
456
|
+
else
|
457
|
+
puts "hit an exception"
|
458
|
+
puts e
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|
462
|
+
end
|
463
|
+
|
464
|
+
PBShippingTutorial::shipping_workflow()
|