pratka 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '04085d85f88834e2907d322efdcf9ab8ccf3588514a5d362c19223f4cd22a790'
4
+ data.tar.gz: ff5582b3d13b92cad8e7fc36de4abd1f44462c67a58ea62031b66628aafedc04
5
+ SHA512:
6
+ metadata.gz: 47c29ce1d7858648f126d8f498c65232f7481e389049b469015dde5aa411a5cd83163dec1117426b97793cbb8fd5d7a146555e5617425c730e6ba52c30c05036
7
+ data.tar.gz: ee5af3f162ba6c5f5a6a893e096e4bfb41bef64a72f30f43ae305ffd152daa2f21a883317dff79b7b03186f117e2c49e728fae76a730fc5bcb745a83d20343ee
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-10-07
4
+
5
+ Initial release, with Speedy as the first supported courier.
6
+
7
+ ### Added
8
+
9
+ - `Pratka::Speedy.configure` for global settings: `base_url`, `language`, `country_id`, `read_timeout`, `open_timeout`
10
+ - `Pratka::Speedy::Client` with username/password authentication
11
+ - Location lookups: `fetch_offices`, `fetch_cities`, `fetch_countries`, `fetch_complexes`, `fetch_streets`
12
+ - `calculate` returns price and delivery deadline for one or more services
13
+ - `create_shipment`
14
+ - `print_label` returns raw PDF or ZPL bytes
15
+ - `fetch_payment_details` returns shipment payouts for a date range
16
+ - `track` returns the operation history for up to 10 parcels
17
+ - Error classes under `Pratka::Speedy`: `TimeoutError`, `ConnectionError`, `HTTPError` (with `status` and `body`), `APIError` (with `code`, `context`, `id`)
18
+ - Validation of required params, which treats blank values as missing
19
+ - Supports Ruby 3.3+ and JRuby
20
+
21
+ [Unreleased]: https://github.com/tripplesteel/pratka/compare/v0.1.0...HEAD
22
+ [0.1.0]: https://github.com/tripplesteel/pratka/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Stilqn Bairski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,460 @@
1
+ # Pratka
2
+
3
+ Ruby client for Bulgarian courier APIs. Supported couriers:
4
+
5
+ - [Econt](https://www.econt.com/developers/soap-json-api.html), [Econt API Models](https://ee.econt.com/services/Shipments/) (Work in progress)
6
+ - [Speedy](https://api.speedy.bg/api/docs/)
7
+
8
+ I built this because I mainly work on Ruby/JRuby on Rails e-shops that integrate with Speedy and Econt, and I kept copying the same API code from project to project. With Pratka, each project can call the courier APIs directly. I'm starting with the endpoints we use most. If you need one that isn't implemented yet, feel free to [open an issue](https://github.com/tripplesteel/pratka/issues).
9
+
10
+ ## Installation
11
+
12
+ Requires Ruby 3.3 or newer
13
+
14
+ ```ruby
15
+ gem "pratka"
16
+ ```
17
+
18
+ or
19
+
20
+ ```bash
21
+ bundle add pratka
22
+ ```
23
+
24
+ ## Speedy
25
+
26
+ ### Setup
27
+ `Pratka::Speedy` works without any configuration. Add the gem, create a `Pratka::Speedy::Client` with your credentials, and you're ready. To override the defaults, set any of `base_url`, `language`, `country_id`, `read_timeout` and `open_timeout`:
28
+
29
+ ```ruby
30
+ Pratka::Speedy.configure do |c|
31
+ c.base_url = ENV["SPEEDY_BASE_URL"] # Defaults to "https://api.speedy.bg/v1/"
32
+ c.language = ENV["SPEEDY_LANGUAGE"] # "BG" or "EN". Anything else falls back to "BG"
33
+ c.country_id = 100 # Defaults to 100 (Bulgaria)
34
+ c.read_timeout = 60 # Seconds. Defaults to 30. nil waits forever
35
+ c.open_timeout = 10 # Seconds. Defaults to 10. nil waits forever
36
+ end
37
+ ```
38
+
39
+ ### Client initialization
40
+
41
+ ```ruby
42
+ client = Pratka::Speedy::Client.new(
43
+ username: ENV["SPEEDY_USERNAME"],
44
+ password: ENV["SPEEDY_PASSWORD"],
45
+ language: "EN", # Optional. Defaults to the configured language
46
+ country_id: 100 # Optional. Defaults to the configured country_id
47
+ )
48
+ ```
49
+
50
+ Credentials go on the client, not the global configuration, so you can run several Speedy accounts side by side
51
+
52
+ ### API methods
53
+
54
+ #### Fetch Speedy offices
55
+
56
+ It's scoped automatically to client's country
57
+
58
+ ```ruby
59
+ client.fetch_offices
60
+ ```
61
+
62
+ Allowed options:
63
+ - `site_id` - Site ID. Limits the search scope in the set of offices for specified site. If omitted - all country offices are searched
64
+ - `site_name` - Filters the results by office site name prefix or part of it
65
+ - `name` - Search term for office name. Filters the results by office name prefix or part of site name
66
+ - `limit` - The number of records to return in response. All records are returned if this parameter is omitted
67
+ - `office_type` - array of ["OFFICE", "APT"]
68
+ - `office_features` - array of ["CARD_PAYMENT", "CASH_PAYMENT", "DROP_OFF", "PICK_UP", "CARGO_TYPE_PARCEL", "CARGO_TYPE_PALLET", "CARGO_TYPE_TYRE"]
69
+
70
+ Returns the parsed JSON response. Values keep their JSON types, so IDs are `Integer`, coordinates are `Float` and flags are `true`/`false`
71
+
72
+ Example response:
73
+
74
+ ```ruby
75
+ {"offices" =>
76
+ [{"id" => 1,
77
+ "name" => "ПЛОВДИВ – СКЛАД ЮГ",
78
+ "nameEn" => "PLOVDIV - WAREHOUSE SOUTH",
79
+ "siteId" => 56784,
80
+ "address" =>
81
+ {"countryId" => 100,
82
+ "siteId" => 56784,
83
+ "siteType" => "гр.",
84
+ "siteName" => "Пловдив",
85
+ "postCode" => "4000",
86
+ "streetId" => 10621,
87
+ "streetType" => "ул.",
88
+ "streetName" => "Кукленско шосе",
89
+ "streetNo" => "15",
90
+ "x" => 24.761268,
91
+ "y" => 42.120139,
92
+ "fullAddressString" => "гр. Пловдив ул. Кукленско шосе No 15",
93
+ "siteAddressString" => "гр. Пловдив",
94
+ "localAddressString" => "ул. Кукленско шосе No 15"},
95
+ "workingTimeFrom" => "08:30",
96
+ "workingTimeTo" => "19:30",
97
+ "workingTimeHalfFrom" => "08:30",
98
+ "workingTimeHalfTo" => "14:30",
99
+ "workingTimeDayOffFrom" => "00:00",
100
+ "workingTimeDayOffTo" => "00:00",
101
+ "sameDayDepartureCutoff" => "19:30",
102
+ "sameDayDepartureCutoffHalf" => "14:30",
103
+ "sameDayDepartureCutoffDayOff" => "19:00",
104
+ "maxParcelDimensions" => {"width" => 240, "height" => 240, "depth" => 600},
105
+ "maxParcelWeight" => 1200.0,
106
+ "type" => "OFFICE",
107
+ "nearbyOfficeId" => 836,
108
+ "workingTimeSchedule" =>
109
+ [{"date" => "2026-10-06",
110
+ "workingTimeFrom" => "08:30",
111
+ "workingTimeTo" => "19:30",
112
+ "sameDayDepartureCutoff" => "19:30",
113
+ "standardSchedule" => true}],
114
+ "validFrom" => "2000-01-01",
115
+ "validTo" => "3000-01-01",
116
+ "cargoTypesAllowed" => ["PALLET", "PARCEL", "TYRE"],
117
+ "pickUpAllowed" => true,
118
+ "dropOffAllowed" => true,
119
+ "routingInformation" =>
120
+ {"tour" => {"number" => 10706, "officeId" => 1, "ramp" => "07", "cell" => "06"},
121
+ "officeId" => 1,
122
+ "hubId" => 1,
123
+ "priority" => 2},
124
+ "cardPaymentAllowed" => true,
125
+ "cashPaymentAllowed" => true,
126
+ "palletOffice" => true
127
+ }]
128
+ }
129
+ ```
130
+
131
+ #### Fetch Speedy cities
132
+ ```ruby
133
+ client.fetch_cities
134
+ ```
135
+
136
+ Returns the cities for the client's `country_id`, which defaults to the configured `country_id` (100, Bulgaria). Use a client with a different `country_id` to fetch another country's cities
137
+
138
+ Speedy serves this endpoint as CSV, so every value is a `String` and empty fields are `nil`. Convert IDs before comparing them with JSON responses. For example, an office's `"siteId" => 56784` matches a city's `"id" => "56784"`
139
+
140
+ Example response:
141
+
142
+ ```ruby
143
+ [{"id" => "14",
144
+ "countryId" => "100",
145
+ "mainSiteId" => "0",
146
+ "type" => "с.",
147
+ "typeEn" => "s.",
148
+ "name" => "Абланица",
149
+ "nameEn" => "Ablanitsa",
150
+ "municipality" => "Хаджидимово",
151
+ "municipalityEn" => "Hadzhidimovo",
152
+ "region" => "Благоевград",
153
+ "regionEn" => "Blagoevgrad",
154
+ "postCode" => "2932",
155
+ "addressNomenclature" => "0",
156
+ "x" => "23.934398",
157
+ "y" => "41.536921",
158
+ "servingDays" => "1111100",
159
+ "servingOfficeId" => "130",
160
+ "servingHubOfficeId" => "13"
161
+ }]
162
+ ```
163
+
164
+ #### Fetch Speedy countries
165
+ ```ruby
166
+ client.fetch_countries
167
+ ```
168
+
169
+ Speedy serves this endpoint as CSV, so every value is a `String` and empty fields are `nil`. Booleans come back as `"true"` and `"false"`
170
+
171
+ Example response:
172
+
173
+ ```ruby
174
+ [{"id" => "36",
175
+ "name" => "Австралия",
176
+ "nameEn" => "Australia",
177
+ "isoAlpha2" => "AU",
178
+ "isoAlpha3" => "AUS",
179
+ "postCodeFormats" => "NNNN",
180
+ "requireState" => "false",
181
+ "addressType" => "2",
182
+ "currencyCode" => nil,
183
+ "defaultOfficeId" => "900",
184
+ "streetTypes" => nil,
185
+ "streetTypesEn" => nil,
186
+ "complexTypes" => nil,
187
+ "complexTypesEn" => nil,
188
+ "siteNomen" => "0"
189
+ }]
190
+ ```
191
+
192
+ #### Fetch complexes
193
+
194
+ ```ruby
195
+ client.fetch_complexes(site_id: 881)
196
+ ```
197
+
198
+ Allowed options:
199
+ - `site_id` (Mandatory) - Site ID
200
+ - `name` - Search term for complex name. Filters the results by complex name prefix or part of complex name
201
+
202
+ Returns the parsed JSON response
203
+
204
+ Example response:
205
+
206
+ ```ruby
207
+ {"complexes" =>
208
+ [{"id" => 16182,
209
+ "siteId" => 881,
210
+ "actualId" => 0,
211
+ "type" => "",
212
+ "typeEn" => "",
213
+ "name" => "Магерови колиби",
214
+ "nameEn" => "Magerovi kolibi"}]}
215
+ ```
216
+
217
+ #### Fetch streets
218
+ ```ruby
219
+ client.fetch_streets(site_id: 36124)
220
+ ```
221
+
222
+ Allowed options:
223
+ - `site_id` (Mandatory) - Site ID
224
+ - `name` - Filters the results by street name
225
+
226
+ Returns the parsed JSON response
227
+
228
+ Example response:
229
+
230
+ ```ruby
231
+ {"streets" =>
232
+ [{"id" => 156597,
233
+ "actualId" => 0,
234
+ "siteId" => 36124,
235
+ "type" => "ул.",
236
+ "typeEn" => "ul.",
237
+ "name" => "Алеко Константинов",
238
+ "nameEn" => "Aleko Konstantinov"}
239
+ ]
240
+ }
241
+ ```
242
+
243
+ #### Print label
244
+
245
+ ```ruby
246
+ client.print_label(paper_size: "A4", parcels: [{ parcel: { id: "123" } }])
247
+ ```
248
+
249
+ Allowed options:
250
+ - `paper_size` (Mandatory) - Paper size of the label
251
+ - `parcels` (Mandatory) - Array of hashes. Example: [ { parcel: { id: "speedy_tracking_number" } } ]
252
+ - `format` - Allowed values are `pdf` or `zpl`. Defaults to `pdf`
253
+ - `printer_name` - Name of the printer
254
+ - `dpi` - Allowed values are `dpi203` or `dpi300`. Defaults to `dpi203`
255
+ - `sender_copy` - Allowed values are `NONE`, `ON_SAME_PAGE`, `ON_SINGLE_PAGE`. Defaults to `NONE`
256
+
257
+ Returns raw PDF or ZPL bytes if the request is successful and the parcel exists
258
+
259
+ If the parcels don't exist in Speedy, `print_label` raises `Pratka::Speedy::Error` with the message `Speedy returned an empty label; check the parcel IDs`
260
+
261
+ If you send empty parcels, `print_label` raises `ArgumentError` with the message `Missing params: parcels`
262
+
263
+ #### Fetch payment details
264
+
265
+ ```ruby
266
+ client.fetch_payment_details(from_date: Time.new(2026, 10, 1), to_date: Time.now, include_details: true)
267
+ ```
268
+
269
+ Allowed options:
270
+ - `from_date` (Mandatory) - Start of the period
271
+ - `to_date` (Mandatory) - End of the period
272
+ - `include_details` - Include per-shipment payout details. Defaults to `false`
273
+
274
+ Dates accept `Date`, `DateTime`, `Time` or a string. The client sends `Date`, `DateTime` and `Time` as `yyyy-MM-dd'T'HH:mm:ssZ`, for example `"2026-10-01T09:00:00+0300"`. A `Date` has no time or zone, so it becomes midnight UTC. Strings are sent as they are
275
+
276
+ Returns the parsed JSON response. `details` is filled only when `include_details` is `true`
277
+
278
+ Example response:
279
+
280
+ ```ruby
281
+ {"payouts" =>
282
+ [{"date" => "2026-10-03",
283
+ "docId" => 123456789,
284
+ "docType" => "POSTAL_MONEY_TRANSFER", # or "CASH"
285
+ "paymentType" => "BANK", # or "CASH"
286
+ "payee" => "Example Ltd",
287
+ "currency" => "BGN",
288
+ "amount" => 59.9,
289
+ "details" =>
290
+ [{"lineNo" => 1,
291
+ "shipmentId" => "61234567890",
292
+ "pickupDate" => "2026-09-30",
293
+ "primaryShipmentPickupDate" => nil,
294
+ "deliveryDate" => "2026-10-01",
295
+ "sender" => "Example Ltd",
296
+ "recipient" => "Ivan Ivanov",
297
+ "note" => "",
298
+ "ref1" => "ORDER-1001",
299
+ "ref2" => "",
300
+ "currency" => "BGN",
301
+ "order" => 1001,
302
+ "amount" => 59.9}]
303
+ }]
304
+ }
305
+ ```
306
+
307
+ #### Create shipment
308
+
309
+ ```ruby
310
+ client.create_shipment(
311
+ recipient: {
312
+ phone1: { number: "0899445566" },
313
+ clientName: "Ivan Ivanov",
314
+ privatePerson: true,
315
+ pickupOfficeId: 77
316
+ },
317
+ service: { serviceId: 505, autoAdjustPickupDate: true },
318
+ content: { parcelsCount: 1, totalWeight: 0.6, contents: "Mobile phone", package: "BOX" },
319
+ payment: { courierServicePayer: "RECIPIENT" },
320
+ shipment_note: "Fragile"
321
+ )
322
+ ```
323
+
324
+ Allowed options:
325
+ - `recipient` (Mandatory) - [ShipmentRecipient](https://api.speedy.bg/api/docs/#href-ds-shipment-recipient). The recipient and the delivery place
326
+ - `service` (Mandatory) - [ShipmentService](https://api.speedy.bg/api/docs/#href-ds-shipment-service). The service level, pickup date and additional services
327
+ - `content` (Mandatory) - [ShipmentContent](https://api.speedy.bg/api/docs/#href-ds-shipment-content). Parcel count, weight, size and contents
328
+ - `payment` (Mandatory) - [ShipmentPayment](https://api.speedy.bg/api/docs/#href-ds-shipment-payment). Who pays for what
329
+ - `sender` - [ShipmentSender](https://api.speedy.bg/api/docs/#href-ds-shipment-sender). The sender and the pickup place. If omitted, the logged-in user is the sender
330
+ - `shipment_note` - Customer's note for the shipment
331
+
332
+ Returns the parsed JSON response. Use the parcel `id` values with `print_label`. Speedy returns `price` only if your account can view shipment amounts, and `deliveryDeadline` only when it knows one
333
+ Example response:
334
+
335
+ ```ruby
336
+ {"id" => "299999990",
337
+ "parcels" => [{"seqNo" => 1, "id" => "299999990"}],
338
+ "pickupDate" => "2026-10-07",
339
+ "price" =>
340
+ {"amount" => 5.5,
341
+ "vat" => 1.1,
342
+ "total" => 6.6,
343
+ "currency" => "BGN"},
344
+ "deliveryDeadline" => "2026-10-08T19:00:00+03:00"
345
+ }
346
+ ```
347
+
348
+ #### Calculate
349
+
350
+ ```ruby
351
+ client.calculate(
352
+ recipient: { privatePerson: true, pickupOfficeId: 77 },
353
+ service: { serviceIds: [505, 412], autoAdjustPickupDate: true },
354
+ content: { parcelsCount: 1, totalWeight: 0.6 },
355
+ payment: { courierServicePayer: "RECIPIENT" }
356
+ )
357
+ ```
358
+
359
+ Allowed options:
360
+ - `recipient` (Mandatory) - [CalculationRecipient](https://api.speedy.bg/api/docs/#href-ds-calculation-recipient). The delivery place
361
+ - `service` (Mandatory) - [CalculationService](https://api.speedy.bg/api/docs/#href-ds-calculation-service). The service IDs to price, the pickup date and additional services
362
+ - `content` (Mandatory) - [CalculationContent](https://api.speedy.bg/api/docs/#href-ds-calculation-content). Parcel count and weight, or a list of parcels
363
+ - `payment` (Mandatory) - [ShipmentPayment](https://api.speedy.bg/api/docs/#href-ds-shipment-payment). Who pays for what
364
+ - `sender` - [CalculationSender](https://api.speedy.bg/api/docs/#href-ds-calculation-sender). The pickup place. If omitted, the logged-in user's location is used
365
+
366
+ Returns the parsed JSON response with one calculation per service ID. Speedy returns `price` only if your account can view shipment amounts, and `deliveryDeadline` only when it knows one
367
+
368
+ A service Speedy can't price for this destination comes back with its own `error` object instead of a price. `calculate` doesn't raise for it, so check each calculation
369
+
370
+ Example response:
371
+
372
+ ```ruby
373
+ {"calculations" =>
374
+ [{"serviceId" => 505,
375
+ "additionalServices" => {},
376
+ "price" =>
377
+ {"amount" => 5.5,
378
+ "vat" => 1.1,
379
+ "total" => 6.6,
380
+ "currency" => "BGN"},
381
+ "pickupDate" => "2026-10-07",
382
+ "deliveryDeadline" => "2026-10-08T19:00:00+03:00"},
383
+ {"serviceId" => 412,
384
+ "error" =>
385
+ {"context" => "service.serviceIds",
386
+ "message" => "Service is not allowed for this destination",
387
+ "id" => "EE-1234",
388
+ "code" => 1}}]
389
+ }
390
+ ```
391
+
392
+ #### Track
393
+
394
+ ```ruby
395
+ client.track(parcels: [{ id: "299999990" }, { ref: "ORDER-1001" }], last_operation_only: true)
396
+ ```
397
+
398
+ Allowed options:
399
+ - `parcels` (Mandatory) - Array of [TrackShipmentParcelRef](https://api.speedy.bg/api/docs/#href-ds-track-shipment-parcel-ref), at most 10. Each parcel needs one of `id`, `ref`, `fullBarcode` or `externalCarrierParcelNumber`. `ref` matches the `ref1` and `ref2` fields of a parcel or shipment
400
+ - `last_operation_only` - Return only the latest operation per parcel. Defaults to `false`
401
+
402
+ Returns the parsed JSON response with one tracked parcel per matched parcel. A `ref` can match up to 10 parcels. Operation codes are listed in [Appendix 1](https://api.speedy.bg/api/docs/#href-appendix1-track-and-trace-codes) of the Speedy docs
403
+
404
+ A parcel Speedy can't find comes back with its own `error` object instead of operations. `track` doesn't raise for it, so check each parcel
405
+
406
+ Speedy asks clients to send at most 10 parcels per request and plans to enforce that limit. `track` enforces it already, so if you send more than 10 parcels it raises `ArgumentError` with the message `Speedy tracks at most 10 parcels per call`. Split larger lists into batches, for example with `each_slice(10)`
407
+
408
+ Example response:
409
+
410
+ ```ruby
411
+ {"parcels" =>
412
+ [{"parcelId" => "299999990",
413
+ "operations" =>
414
+ [{"dateTime" => "2026-10-08T11:42:10+0300",
415
+ "operationCode" => -14,
416
+ "description" => "Delivered",
417
+ "place" => "SOFIA",
418
+ "consignee" => "Ivan Ivanov"}]}]
419
+ }
420
+ ```
421
+
422
+ ### Errors
423
+
424
+ Every network and API failure raises a subclass of `Pratka::Speedy::Error`, which inherits from `Pratka::Error`
425
+
426
+ | Error | Raised when | Extra attributes |
427
+ | --- | --- | --- |
428
+ | `Pratka::Speedy::TimeoutError` | The connection or read times out | |
429
+ | `Pratka::Speedy::ConnectionError` | DNS, socket or SSL failure | |
430
+ | `Pratka::Speedy::HTTPError` | Speedy returns a non-2xx status | `status`, `body` |
431
+ | `Pratka::Speedy::APIError` | Speedy returns 2xx with an `error` object in the body | `code`, `context`, `id` |
432
+ | `Pratka::Speedy::Error` | The response is malformed, has invalid JSON, or has an unexpected format | |
433
+
434
+ Bad arguments raise `ArgumentError` before any request is sent, for example an unknown option or a missing `site_id`
435
+
436
+ ```ruby
437
+ begin
438
+ client.fetch_streets(site_id: 36124, name: "Алеко")
439
+ rescue Pratka::Speedy::APIError => e
440
+ logger.warn("Speedy rejected the request: #{e.message} (code #{e.code})")
441
+ rescue Pratka::Speedy::TimeoutError, Pratka::Speedy::ConnectionError
442
+ retry_later
443
+ rescue Pratka::Speedy::Error => e
444
+ logger.error("Speedy failed: #{e.message}")
445
+ end
446
+ ```
447
+
448
+ ## Development
449
+
450
+ ```bash
451
+ bin/setup # install dependencies
452
+ bundle exec rake # run specs and RuboCop
453
+ bin/console # IRB session with the gem loaded
454
+ ```
455
+
456
+ To release a new version, update `lib/pratka/version.rb` and `CHANGELOG.md`, then run `bundle exec rake release`. That task tags the commit, pushes the tag and publishes the gem to [rubygems.org](https://rubygems.org)
457
+
458
+ ## License
459
+
460
+ [MIT](LICENSE.txt)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pratka
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Pratka
6
+ module Speedy
7
+ # Authenticated client for the Speedy courier API
8
+ class Client
9
+ OFFICES_ENDPOINT = "location/office"
10
+ OFFICES_PARAMS = {
11
+ site_id: :siteId,
12
+ site_name: :siteName,
13
+ name: :name,
14
+ limit: :limit,
15
+ office_type: :officeType,
16
+ office_features: :officeFeatures
17
+ }.freeze
18
+
19
+ CITIES_ENDPOINT = "location/site/csv" # Speedy wants to add Country ID at the end of the endpoint. Example location/site/csv/100
20
+ COUNTRIES_ENDPOINT = "location/country/csv"
21
+
22
+ COMPLEX_ENDPOINT = "location/complex"
23
+ COMPLEX_PARAMS = { site_id: :siteId, name: :name }.freeze
24
+
25
+ STREET_ENDPOINT = "location/street"
26
+ STREET_PARAMS = { site_id: :siteId, name: :name }.freeze
27
+
28
+ PRINT_ENDPOINT = "print"
29
+ PRINT_PARAMS = {
30
+ format: :format,
31
+ paper_size: :paperSize,
32
+ printer_name: :printerName,
33
+ dpi: :dpi,
34
+ sender_copy: :additionalWaybillSenderCopy,
35
+ parcels: :parcels
36
+ }.freeze
37
+
38
+ PAYMENTS_ENDPOINT = "payments"
39
+ PAYMENTS_PARAMS = {
40
+ from_date: :fromDate,
41
+ to_date: :toDate,
42
+ include_details: :includeDetails
43
+ }.freeze
44
+
45
+ CREATE_SHIPMENT_ENDPOINT = "shipment"
46
+ SHIPMENT_PARAMS = {
47
+ sender: :sender,
48
+ recipient: :recipient,
49
+ service: :service,
50
+ content: :content,
51
+ payment: :payment,
52
+ shipment_note: :shipmentNote
53
+ }.freeze
54
+
55
+ CALCULATION_ENDPOINT = "calculate"
56
+ CALCULATION_PARAMS = {
57
+ sender: :sender,
58
+ recipient: :recipient,
59
+ service: :service,
60
+ content: :content,
61
+ payment: :payment
62
+ }.freeze
63
+
64
+ TRACK_ENDPOINT = "track"
65
+ TRACK_PARAMS = { parcels: :parcels, last_operation_only: :lastOperationOnly }.freeze
66
+
67
+ # Speedy recommends 10 parcels per request and plans to enforce it
68
+
69
+ TRACK_MAX_PARCELS = 10
70
+
71
+ def initialize(username:, password:, language: nil, country_id: Speedy.configuration.country_id)
72
+ @username = username
73
+ @password = password
74
+ @language = decide_language(language)
75
+ @country_id = country_id
76
+ end
77
+
78
+ # Fetch all offices from Speedy
79
+ # Allowed params:
80
+ # site_id - Site id. Limits the search scope in the set of offices for specified site. If omitted - all country offices are searched
81
+ # site_name - Filters the results by office site name prefix or part of it
82
+ # name - Search term for office name. Filters the results by office name prefix or part of site name
83
+ # limit - The number of records to return in response. All records are returned if this parameter is omitted
84
+ # office_type - array of ["OFFICE", "APT"]
85
+ # office_features - array of ["CARD_PAYMENT", "CASH_PAYMENT", "DROP_OFF", "PICK_UP", "CARGO_TYPE_PARCEL", "CARGO_TYPE_PALLET", "CARGO_TYPE_TYRE"][]
86
+ # Returns the parsed JSON response
87
+ def fetch_offices(**options)
88
+ call(OFFICES_ENDPOINT, map_params(options, OFFICES_PARAMS).merge(countryId: @country_id))
89
+ end
90
+
91
+ # Fetch all cities available in Speedy
92
+ # Returns the parsed array of hashes
93
+ def fetch_cities
94
+ fetch_csv("#{CITIES_ENDPOINT}/#{@country_id}")
95
+ end
96
+
97
+ # Fetch all countries available in Speedy
98
+ # Returns the parsed array of hashes
99
+ def fetch_countries
100
+ fetch_csv(COUNTRIES_ENDPOINT)
101
+ end
102
+
103
+ # List all complexes in Speedy
104
+ # Allowed params:
105
+ # site_id - (Mandatory)
106
+ # name - Search term for complex name. Filters the results by complex name prefix or part of complex name
107
+ def fetch_complexes(**options)
108
+ call(COMPLEX_ENDPOINT, map_params(options, COMPLEX_PARAMS, required: [:site_id]))
109
+ end
110
+
111
+ # List all streets in Speedy
112
+ # Allowed params:
113
+ # site_id - (Mandatory)
114
+ # name - Search term for street name. Filters the results by street name prefix or part of street name
115
+ def fetch_streets(**options)
116
+ call(STREET_ENDPOINT, map_params(options, STREET_PARAMS, required: [:site_id]))
117
+ end
118
+
119
+ # Print label for your parcels. Returns raw PDF or ZPL bytes
120
+ # Allowed params:
121
+ # paper_size - (Mandatory)
122
+ # parcels - (Mandatory) - Array of hashes. Example: [ { "parcel" => { id: 'speedy_tracking_number' } } ]
123
+ # format - Allowed values are `pdf` or `zpl`. Default one is `pdf`
124
+ # printer_name
125
+ # dpi - Allowed values are `dpi203` or `dpi300`. Default one is `dpi203`
126
+ # sender_copy - Allowed values are `NONE`, `ON_SAME_PAGE`, `ON_SINGLE_PAGE`. Default one is `NONE`
127
+ def print_label(**options)
128
+ label = call(PRINT_ENDPOINT, map_params(options, PRINT_PARAMS, required: %i[paper_size parcels]))
129
+ raise Error, "Speedy returned an empty label; check the parcel IDs" unless label.is_a?(String) && !label.empty?
130
+
131
+ # HTTP labels every body UTF-8; labels are binary
132
+
133
+ label.b
134
+ end
135
+
136
+ # Fetch shipment payouts for a period
137
+ # Allowed params:
138
+ # from_date - (Mandatory) Date, DateTime, Time or a "yyyy-MM-ddTHH:mm:ss+zzzz" string
139
+ # to_date - (Mandatory) Date, DateTime, Time or a "yyyy-MM-ddTHH:mm:ss+zzzz" string
140
+ # include_details - Include per-shipment payout details. Default one is false
141
+ def fetch_payment_details(**options)
142
+ params = map_params(options, PAYMENTS_PARAMS, required: %i[from_date to_date])
143
+ params[:fromDate] = format_datetime(params[:fromDate])
144
+ params[:toDate] = format_datetime(params[:toDate])
145
+
146
+ call(PAYMENTS_ENDPOINT, params)
147
+ end
148
+
149
+ # Create shipment
150
+ # [Create Shipment Request (CreateShipmentRequest)](https://api.speedy.bg/api/docs/#href-create-shipment-req)
151
+ # Allowed params:
152
+ # sender - ShipmentSender - Defines the sender of the shipment and shipment's pickup place. If not specified, the logged user is considered as a sender
153
+ # recipient (Mandatory) - ShipmentRecipient - Defines the recipient of the shipment and shipment’s delivery place
154
+ # service (Mandatory) - ShipmentService - Defines shipment service level agreement
155
+ # content (Mandatory) - ShipmentContent - Defines shipment’s content - number of parcels, weight, size, etc
156
+ # payment (Mandatory) - ShipmentPayment - Defines who-pays-what in shipment and other payment parameters
157
+ # shipment_note - String - Customer’s note associated with the shipment
158
+ def create_shipment(**options)
159
+ call(CREATE_SHIPMENT_ENDPOINT, map_params(options, SHIPMENT_PARAMS, required: %i[recipient service content payment]))
160
+ end
161
+
162
+ # Calculate shipment price and delivery deadline for one or more services
163
+ # [Calculation Request (CalculationRequest)](https://api.speedy.bg/api/docs/#href-calculation-req)
164
+ # Allowed params:
165
+ # sender - CalculationSender - Defines the pickup place. If not specified, the logged user's location is used
166
+ # recipient (Mandatory) - CalculationRecipient - Defines the delivery place
167
+ # service (Mandatory) - CalculationService - Defines the service ids to price and the pickup date
168
+ # content (Mandatory) - CalculationContent - Defines number of parcels, weight, etc
169
+ # payment (Mandatory) - ShipmentPayment - Defines who-pays-what in shipment
170
+ # Returns one calculation per service id. Each calculation carries its own error if Speedy can't price that service
171
+ def calculate(**options)
172
+ call(CALCULATION_ENDPOINT, map_params(options, CALCULATION_PARAMS, required: %i[recipient service content payment]))
173
+ end
174
+
175
+ # Track parcels and return their operation history
176
+ # [Track Request (TrackRequest)](https://api.speedy.bg/api/docs/#href-track-req)
177
+ # Allowed params:
178
+ # parcels (Mandatory) - TrackShipmentParcelRef[] - Up to 10 parcels. Each needs one of id, ref, fullBarcode or externalCarrierParcelNumber
179
+ # last_operation_only - Boolean - Return only the latest operation per parcel. Default one is false
180
+ # Returns one tracked parcel per matched parcel. A ref can match up to 10. Each tracked parcel carries its own error if Speedy can't track it
181
+ def track(**options)
182
+ params = map_params(options, TRACK_PARAMS, required: [:parcels])
183
+ raise ArgumentError, "Speedy tracks at most #{TRACK_MAX_PARCELS} parcels per call" if params[:parcels].size > TRACK_MAX_PARCELS
184
+
185
+ call(TRACK_ENDPOINT, params)
186
+ end
187
+
188
+ private
189
+
190
+ def call(endpoint, data = {})
191
+ http.call(endpoint, data)
192
+ end
193
+
194
+ def map_params(options, mapping, required: [])
195
+ unknown = options.keys - mapping.keys
196
+ raise ArgumentError, "Unknown params: #{unknown.join(", ")}" if unknown.any?
197
+
198
+ missing = required.select { |key| blank?(options[key]) }
199
+ raise ArgumentError, "Missing params: #{missing.join(", ")}" if missing.any?
200
+
201
+ options.transform_keys(mapping)
202
+ end
203
+
204
+ def blank?(value)
205
+ value = value.strip if value.respond_to?(:strip)
206
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
207
+ end
208
+
209
+ # Speedy wants yyyy-MM-dd'T'HH:mm:ssZ, where Z is an offset like +0300. A Date becomes midnight UTC
210
+ def format_datetime(value)
211
+ value.respond_to?(:strftime) ? value.strftime("%Y-%m-%dT%H:%M:%S%z") : value
212
+ end
213
+
214
+ def fetch_csv(endpoint)
215
+ result = call(endpoint)
216
+ raise Error, "Expected CSV from #{endpoint}, got #{result.class}" unless result.is_a?(String)
217
+
218
+ CSV.parse(result.delete_prefix("").strip, headers: true).map(&:to_h)
219
+ end
220
+
221
+ def http
222
+ @http ||= HTTP.new(@username, @password, @language)
223
+ end
224
+
225
+ def decide_language(language)
226
+ return language if ["BG", "EN"].include?(language)
227
+
228
+ Speedy.configuration.language
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pratka
4
+ module Speedy
5
+ class Configuration
6
+ attr_writer :base_url, :language, :country_id
7
+ attr_accessor :read_timeout, :open_timeout
8
+
9
+ DEFAULT_BASE_URL = "https://api.speedy.bg/v1/"
10
+ DEFAULT_LANGUAGE = "BG"
11
+ DEFAULT_COUNTRY_ID = 100
12
+ DEFAULT_READ_TIMEOUT = 30
13
+ DEFAULT_OPEN_TIMEOUT = 10
14
+
15
+ def initialize
16
+ @base_url = DEFAULT_BASE_URL
17
+ @language = DEFAULT_LANGUAGE
18
+ @country_id = DEFAULT_COUNTRY_ID
19
+ @read_timeout = DEFAULT_READ_TIMEOUT
20
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
21
+ end
22
+
23
+ def base_url
24
+ Util::StringHelper.nil_or_value(@base_url) || DEFAULT_BASE_URL
25
+ end
26
+
27
+ def language
28
+ return @language if ["BG", "EN"].include?(@language)
29
+
30
+ DEFAULT_LANGUAGE
31
+ end
32
+
33
+ def country_id
34
+ Util::StringHelper.nil_or_value(@country_id) || DEFAULT_COUNTRY_ID
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+
5
+ module Pratka
6
+ module Speedy
7
+ class Error < Pratka::Error; end
8
+ class TimeoutError < Error; end
9
+ class ConnectionError < Error; end
10
+
11
+ class HTTPError < Error
12
+ attr_reader :status, :body
13
+
14
+ def initialize(status, body)
15
+ @status = status
16
+ @body = body
17
+ super(build_message)
18
+ end
19
+
20
+ private
21
+
22
+ # The body often holds the only explanation, e.g. a request parsing error on a 400
23
+ def build_message
24
+ detail = @body.to_s.gsub(/\s+/, " ").strip
25
+ return "Speedy returned HTTP #{@status}" if detail.empty?
26
+
27
+ detail = "#{detail[0, 200]}..." if detail.length > 200
28
+ "Speedy returned HTTP #{@status}: #{detail}"
29
+ end
30
+ end
31
+
32
+ class APIError < Error
33
+ attr_reader :code, :context, :id
34
+
35
+ def initialize(error)
36
+ error = { "message" => error.to_s } unless error.is_a?(Hash)
37
+
38
+ @code = error["code"]
39
+ @context = error["context"]
40
+ @id = error["id"]
41
+ super(error["message"] || "Speedy API error")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+
6
+ module Pratka
7
+ module Speedy
8
+ # Net::HTTP class to handle requests to the Speedy API
9
+ class HTTP
10
+ def initialize(username, password, language)
11
+ @username = username
12
+ @password = password
13
+ @language = language
14
+ end
15
+
16
+ # Each call opens a fresh connection and closes it when the block exits
17
+ def call(endpoint, data)
18
+ response = Net::HTTP.start(base_uri.host, base_uri.port, **connection_options) do |http|
19
+ http.request(build_request(endpoint, data))
20
+ end
21
+ body = (+response.body.to_s).force_encoding(Encoding::UTF_8)
22
+
23
+ raise HTTPError.new(response.code.to_i, body) unless response.is_a?(Net::HTTPSuccess)
24
+ return body unless json?(response)
25
+
26
+ parsed = JSON.parse(body)
27
+ raise APIError.new(parsed["error"]) if parsed.is_a?(Hash) && parsed["error"]
28
+
29
+ parsed
30
+ rescue Timeout::Error => e
31
+ raise TimeoutError, e.message
32
+ rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
33
+ raise ConnectionError, e.message
34
+ rescue Net::HTTPBadResponse, Zlib::Error => e
35
+ raise Error, "Malformed response from Speedy: #{e.message}"
36
+ rescue JSON::ParserError => e
37
+ raise Error, "Invalid JSON from Speedy: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def json?(response)
43
+ response.content_type.to_s.include?("json")
44
+ end
45
+
46
+ # Speedy wants credentials in its data payload
47
+ def data_with_credentials(data)
48
+ { userName: @username, password: @password, language: @language }.merge(data)
49
+ end
50
+
51
+ def base_uri
52
+ @base_uri ||= URI(safe_base_url)
53
+ end
54
+
55
+ def safe_base_url
56
+ url = Speedy.configuration.base_url
57
+ url.end_with?("/") ? url : "#{url}/"
58
+ end
59
+
60
+ def connection_options
61
+ {
62
+ use_ssl: base_uri.scheme == "https",
63
+ read_timeout: Speedy.configuration.read_timeout,
64
+ open_timeout: Speedy.configuration.open_timeout
65
+ }
66
+ end
67
+
68
+ def build_request(endpoint, data)
69
+ req = Net::HTTP::Post.new(base_uri.path + endpoint, "Content-Type" => "application/json")
70
+ req.body = data_with_credentials(data).to_json
71
+
72
+ req
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "speedy/errors"
4
+ require_relative "speedy/configuration"
5
+ require_relative "speedy/client"
6
+ require_relative "speedy/http"
7
+
8
+ module Pratka
9
+ module Speedy
10
+ class << self
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield configuration
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pratka
4
+ module Util
5
+ module StringHelper
6
+ class << self
7
+ def nil_or_value(value)
8
+ (value.nil? || value.to_s.strip.empty?) ? nil : value
9
+ end
10
+
11
+ def present?(value)
12
+ !(value.nil? || value.to_s.strip.empty?)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pratka
4
+ VERSION = "0.1.0"
5
+ end
data/lib/pratka.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pratka/util/string_helper"
4
+ require_relative "pratka/version"
5
+ require_relative "pratka/error"
6
+ require_relative "pratka/speedy"
7
+
8
+ module Pratka
9
+ end
data/sig/pratka.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Pratka
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pratka
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stilqn Bairski
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.3'
26
+ description: One Ruby interface for shipping, tracking and office lookup across Bulgarian
27
+ couriers.
28
+ email:
29
+ - steelbairski@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - lib/pratka.rb
38
+ - lib/pratka/error.rb
39
+ - lib/pratka/speedy.rb
40
+ - lib/pratka/speedy/client.rb
41
+ - lib/pratka/speedy/configuration.rb
42
+ - lib/pratka/speedy/errors.rb
43
+ - lib/pratka/speedy/http.rb
44
+ - lib/pratka/util/string_helper.rb
45
+ - lib/pratka/version.rb
46
+ - sig/pratka.rbs
47
+ homepage: https://github.com/tripplesteel/pratka
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ allowed_push_host: https://rubygems.org
52
+ homepage_uri: https://github.com/tripplesteel/pratka
53
+ changelog_uri: https://github.com/tripplesteel/pratka/blob/main/CHANGELOG.md
54
+ bug_tracker_uri: https://github.com/tripplesteel/pratka/issues
55
+ rubygems_mfa_required: 'true'
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 3.3.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 4.0.22
71
+ specification_version: 4
72
+ summary: Ruby client for Bulgarian courier APIs (Speedy).
73
+ test_files: []