Appearance
Transporter Integrations (Courier APIs)
Flow ID: IN-09 Module(s): src/Transporters Complexity: High Last Updated: 2026-09-29
Business Context
17 shipping courier APIs integrated for voucher creation, cancellation, tracking, label printing, and pickup scheduling. Each transporter has a dedicated class in src/Transporters/ with config, helper, and main API class.
Integrated Transporters
| # | Provider | Class | Protocol | Auth |
|---|---|---|---|---|
| 1 | ACS Courier | Acs | REST JSON | Multi-credential (Company+User+API Key) |
| 2 | ACS (SOAP fallback) | AcsSoap | SOAP 1.1 | Same as ACS |
| 3 | Geniki Taxydromiki v1 | Geniki | SOAP 1.1 (curl) | Username + Password |
| 4 | Geniki Taxydromiki v2 | GenikiV2 | SOAP 1.2 (SoapClient) | Username + Password + App Key |
| 5 | DHL Express | Dhl | REST JSON (Guzzle) | HTTP Basic Auth |
| 6 | ELTA Couriers | Elta | SOAP 1.1 (custom XML) | Embedded in SOAP |
| 7 | Speedex | Speedex | SOAP 1.2 (SoapClient) | Session-based (login → SessionID) |
| 8 | Courier Center | Center | REST JSON (QualcoProvider) | Context object (UserAlias+Credential+ApiKey) |
| 9 | EasyMail | EasyMail | SOAP 1.1 (SoapClient) | Credentials object |
| 10 | FIS | Fis | SOAP 1.1 (SoapClient) | Web Service Code |
| 11 | Box Now | BoxNow | REST JSON | OAuth 2.0 (Client Credentials) |
| 12 | Taxydema v1 | Taxydema | SOAP 1.1 (custom) | Embedded in SOAP |
| 13 | Taxydema v2 | TaxydemaV2 | REST JSON (QualcoProvider) | Context object |
| 14 | Cyprus Post | CyprusPost | REST JSON (Guzzle) | Bearer Token |
| 15 | ASAP (Last Mily) | Asap | REST JSON | HMAC-SHA256 signed |
| 16 | Daily Courier | DailyCourier | REST JSON (Guzzle) | Bearer Token |
| 17 | Skroutz Last Mile | Skroutz | REST JSON (Guzzle) | Bearer Token |
Common Operations
| Operation | Method | Description |
|---|---|---|
| Create voucher | createVoucher() | Generate shipping label with tracking code |
| Cancel voucher | cancelVoucher() / deleteVoucher() | Cancel/delete shipment |
| Track & trace | trackAndTrace() | Get shipment status history |
| Print voucher | getVoucherPdf() | Download shipping label PDF |
| Close pending | closePendingJobs() | Create pickup list/dispatch |
| Get picking points | getPickingPoints() | Locker/collection point locations |
| Live pickup-point catalog | SmartPointCatalogService::fetch(int $transporterId, ?CatalogFilter $filter = null) | Aggregates getPickingPoints()/fetch() across one transporter via external API; exposed via GET /rest/transporter/{id}/smart-point (guest). (src/Domains/Transporter/SmartPointCatalog/Service.php:37) |
| Batch label retrieval (BoxNow only) | getLabels(array $parcelIds): ?string | Calls the carrier's POST /labels:search and returns a multi-label PDF directly — no client-side merging (src/Transporters/BoxNow/BoxNow.php:159-184, request built at :171) |
BoxNow batch labels (#632). Unlike ACS (chunks of 10) and Center (chunks of 20), getLabels() deliberately does NO chunking: the carrier documents no maximum, 200 orders is a tested floor rather than a known ceiling, and mergePdfs() hardcodes ACS label geometry so it is unusable for A4. Sheet layout (parcelIds, paperSize, perPage) is read from config, not passed in as call arguments (BoxNow.php:172-178). The PAPER_SIZE/LABELS_PER_PAGE settings that drive this are documented in AD-06 Transporter Admin; the admin bulk action that triggers batch printing is documented in AD-03 Order Management.
Base Classes
QualcoProvider: Shared base for Center and TaxydemaV2 (REST JSON with Context auth)BaseTransporterConfig: Standard config interface withgetFieldConfiguration()for admin UI schema andinitialize()for settings loading
Protocol Distribution
- REST JSON: 9 providers (DHL, BoxNow, ASAP, DailyCourier, Skroutz, Center, TaxydemaV2, CyprusPost, ACS)
- SOAP 1.1: 6 providers (ACS SOAP, Geniki v1, EasyMail, Elta, Taxydema v1, FIS)
- SOAP 1.2: 2 providers (Geniki v2, Speedex)
Environment Support
Most providers support dual environments (testing/production) via config toggle. SSL verification uses cached cacert.pem in development, true in production.
BoxNow request-timeout seam is now live (#632). buildOptions() (src/Transporters/BoxNow/BoxNow.php:287) always read $this->config->timeout, but no config declared the property, so BaseTransporterConfig::__get() returned null and the guard was unreachable — every BoxNow request ran with Guzzle's unbounded default. BoxNowConfig::$timeout is now declared (src/Transporters/BoxNow/BoxNowConfig.php:44) defaulting to 0, so behaviour is unchanged out of the box but the guard is now reachable. Code-level only: no registry key, no getFieldConfiguration() entry, not read in initialize().
Live Platform Data
Aggregate statistics from production databases across all client deployments:
Transporter configuration: 14 transporters configured (8 active):
- Active: ACS, ACS-CY, DHL, SVUUM, Courier Center, BOXNOW + 2 more
- 6 inactive/standby configurations
Shipping volume data:
- 14,537 total pricing data points across 5 pricing tables (weight-based, zone-based, flat-rate, volumetric, smart-point)
- 150,000+ smart point deliveries (locker/collection point), representing ~12% of all orders
- 3 store locations configured (all active, all GR)
Smart Point Tracking (shop_order_smart_point table):
| Column | Type | Description |
|---|---|---|
id | int (PK, AI) | Row identifier |
order_id | int (FK) | Associated order |
transporter_id | int (FK) | Transporter providing the smart point |
shop_id | int (FK) | Shop/store location |
json_data | text | Smart point metadata (locker ID, address, provider details) |
Smart Point Catalog API
Two distinct smart-point surfaces exist in the REST API — do not conflate them:
| Endpoint | Purpose | Auth | Storage |
|---|---|---|---|
GET /rest/order/smart-point | Per-order saved pickup-point row | auth=backend, roles [ADMIN, ORDERS] (application/config/rest_policies.php:776) | shop_order_smart_point DB row |
GET /rest/transporter/{id}/smart-point | Live pickup-point catalog for one transporter | auth=guest (application/config/rest_policies.php:842-843) | No DB storage — live external API call, cached 10 min |
Provider activation gating
Service::fetch() (src/Domains/Transporter/SmartPointCatalog/Service.php:37-86) applies a 3-stage gate:
- Transporter row exists in DB AND
active=1— else404 unknown_transporter(:40-42) class_nameis in$config['smartPoints'](application/config/app.php:436-459, 12 providers) — else409 no_provider(:47-50)provider->areSmartPointsEnabledForThisProvider()returns true — else409 not_enabled_for_provider(:56-58)
The former stage 4 (409 widget_only, from shouldFetchSmartPoints()) is gone (#810): the widget preference (USE_TRANSPORTER_WIDGET) is a rendering choice of the legacy storefront, not a provider capability, so it no longer refuses the catalog — a headless client that cannot run the vendor widget still needs the list (comment at Service.php:59-66; SmartPointsDisabledException now has only no_provider and not_enabled_for_provider).
Provider shape normalization
The SmartPoints provider implementations (src/SmartPoints/Transporters/) return the legacy storefront shape that Adv_order::smartPointsInitialize() consumes — a nested array keyed by class_name:
[ $class_name => [
'smartPointStationData' => SmartPointDTO[],
'smartPointsEnable' => bool,
'responseError'? => string
] ]SmartPointCatalogService::normalize($result, $className) (src/Domains/Transporter/SmartPointCatalog/Service.php:149-166) unwraps $result[$className]['smartPointStationData'] into a flat array_values() list before the isMalformed() guard runs. Already-flat lists (empty [] from stub providers) pass through unchanged via isFlatDtoList() (:168-176). The isMalformed() guard (:178-181) runs after normalization as a post-normalization safety net — a genuinely unknown shape still produces a TransporterApiException (502). The malformed check also runs inside the cached closure (:78-80) and on cache hits (:111), so a malformed cached entry is discarded and refetched. The provider implementations are never touched; the legacy storefront continues to receive the nested shape via its own call path.
Payload schema
Each pickup point in the response collection is a SmartPointDTO with 17 fields (src/Rest/Transporter/Resources/SmartPoint/Resource.php:9-30):
| Field | Type | Nullable |
|---|---|---|
| id | string | no |
| name | string | no |
| address | string | no |
| city | string | yes |
| country | string | no |
| latitude | float | no |
| longitude | float | no |
| postalCode | integer | no |
| image | string | yes |
| note | string | yes |
| title | string | yes |
| type | string | yes |
| string | yes | |
| workingHours | string | yes |
| phone | string | yes |
| stationDestination | string | yes |
| stationBranchDestination | integer | yes |
SmartPointAvailabilityChecker (feature-flag helper)
src/Domains/Transporter/SmartPointCatalog/SmartPointAvailabilityChecker.php (:isAvailable()) was added to the SmartPointCatalog namespace to serve the GET /rest/features discovery endpoint (#287); it is distinct from Service::fetch() — it performs no external API call, only a single SELECT 1 ... WHERE active=1 AND class_name IN (...) LIMIT 1 DB existence check via Repository::hasActiveSmartPointProvider(array $classNames): bool (src/Domains/Transporter/Transporter/Repository/Repository.php).
Catalog caching
The normalised catalog is cached for CACHE_TTL_SECONDS = 600 (10 minutes) per transporter via Service::cached(), under the key smart-point-catalog:{id} (src/Domains/Transporter/SmartPointCatalog/Service.php:21,68-85,98-125). The adapter is cache.l2, injected as $cache (src/Domains/Transporter/container.php:91-94). The cache is optional (?CacheAdapterInterface $cache = null, Service.php:27): with no adapter every request fetches live (:100-102). A cache read or write failure is treated as a miss and never fails the request (:106-110,117-121). Query filters are applied after the cache, so one upstream fetch serves every filtered read for the TTL.
Query filters (#810)
GET /rest/transporter/{id}/smart-point accepts four optional query keys, parsed by CatalogFilter::fromQuery() (src/Domains/Transporter/SmartPointCatalog/CatalogFilter.php:53-99):
| Key | Rule |
|---|---|
country | ISO 3166-1 alpha-2 (/^[A-Za-z]{2}$/), upper-cased; only points in that country |
near | "lat,lng", range-checked; keeps points within radius and sorts them distance-ascending |
radius | Positive integer metres, default 25000 (DEFAULT_RADIUS_METRES); ignored without near |
limit | Positive integer; applied last |
CatalogFilter::apply() filters, sorts and limits the normalised DTO list (:106-139). A key that is present but does not parse raises InvalidCatalogQueryException, which the controller maps to 400 with error.code = invalid_query_params and error.key naming the offender (src/Rest/Transporter/Controllers/SmartPoint.php:92-100).
External Rate Catalog API
Two REST endpoints expose live carrier pricing and availability lookups. Neither caches results. Both are guest-accessible on the same rationale as the legacy storefront equivalents they wrap.
GET /rest/transporter/{id}/dhl-rates
- Route:
application/config/rest_routes.php:1057-1058(+ locale-prefixed variant) - Auth: guest (
application/config/rest_policies.php:851-852) - Controller:
src/Rest/Transporter/Controllers/DhlRates.php - Service:
src/Domains/Transporter/ExternalRates/DhlRatesService.php - What it does: wraps legacy
AdvTransporters::baseGetDhlRates(). Hits the DHL Rates API live (no cache). Returnslist<{productName, productCode, price, pickupDateTime}>. - Query params:
destinationCountryCode(required)weightin grams (required, >0; divided by 1000 for DHL kg atDhlRatesService.php:77)destinationPostalCode(optional)
- Status codes: 200 list; 400
missing_query_params/invalid_weight; 404unknown_transporter(row missing oractive != 1); 409carrier_class_mismatch(transporterclass_name !== 'DHL'); 502transporter_unavailable - Testability:
$dhlFactoryis injectable (DhlRatesService.php:34-40); production default buildsnew Dhl(new DhlConfig($settings)) - Added in commit
0d06e18b0(#113f)
GET /rest/transporter/{id}/asap-services
- Route:
application/config/rest_routes.php:1059-1060; auth: guest (application/config/rest_policies.php:853-854) - Controller:
src/Rest/Transporter/Controllers/AsapServices.php - Service:
src/Domains/Transporter/ExternalRates/AsapServicesService.php - What it does: multi-hop — geocodes customer address (OpenStreetMaps), fetches carrier depot list, queries ASAP for available services. Thin bridge over legacy
AdvTransporters::getAsapServices()(porting rejected as not worth refactor — docblock atAsapServicesService.php:10-29). Server-side cutoff-time filter and Next Day/Express whitelist apply per the legacy contract. Returns the same{productName, productCode, price, pickupDateTime}shape as the DHL endpoint. - Query params:
country,weight(grams),address,city(all required)postalCode,county(optional)
- Status codes: same set as DHL; 409 fires on
class_name !== 'ASAP' - Added in commit
0d06e18b0(#113f)
ExternalRates exception classes
New namespace Advisable\Domains\Transporter\ExternalRates\Exceptions (distinct from SmartPointCatalog's exceptions):
| Exception | Trigger | HTTP status |
|---|---|---|
UnknownTransporterException | Transporter row missing or active != 1 | 404 unknown_transporter |
CarrierClassMismatchException | class_name doesn't match endpoint (carries transporterId, expectedClassName, actualClassName) | 409 carrier_class_mismatch |
TransporterApiException | Wraps any \Throwable from upstream carrier/geocoding API | 502 transporter_unavailable |
Carrier Data in REST Checkout
/rest/checkout/shipping returns three carrier discriminators per option from ShippingCalculator: className (transporter class_name), deliveryOptionType (Registry-configured), requiresCarrierData (true iff DHL/ASAP/SmartPoint-capable AND delivery option allows pickup). After order placement, CarrierDataDispatcher routes the transporterExternalData blob to the matching persister (DhlVoucherPersister → shop_order_dhl_vouchers, AsapDataPersister → shop_order_asap_data, SmartPointPersister → shop_order_smart_point). Full detail in CF-06.
Pricing fields, and the options-leak fix (#568): alongside those three discriminators, each option now carries the threshold-aware cost (free-shipping threshold, TRANS_FREE_ALL, and the overweight per-kg surcharge already applied — previously the raw transporters_options_pricing row cost, ignoring the cart entirely), plus two new fields: overweightCost (display-only duplicate of the surcharge already inside cost — never sum it into a total) and deliveryCost (the cash-on-delivery surcharge, non-zero only when the request's payWay is delivery). Separately, the per-option options array — the genuinely selectable extras (insurance, Saturday delivery, …) — no longer includes the 7 transporters_options_pricing pricing-control reg_keys (TRANS_COST_LIMIT, WEIGHT_LIMIT, PRICE_PER_KG, TRANS_FREE_ALL, DELIVERY_COST, DELIVERY_COST_MIN_FREE, MIN_ORDER_AMOUNT); before #568 those were serialized onto the wire as tickable {id, name, extraCost} entries, so a headless client could select a pricing control as if it were a purchasable option. See CF-06 §Shipping Cost Calculation (Modern REST) for the full calculation and AD-06 §transporters_options_pricing for the key inventory.
Known Issues & Security Gaps
[RESOLVED — commit
5128ad7d5] BoxNow/Skroutz/ACS provider shape mismatch caused 502 onGET /rest/transporter/{id}/smart-point.SmartPointCatalogService::normalize()(src/Domains/Transporter/SmartPointCatalog/Service.php:149-166) unwraps the nested-by-class_nameprovider shape to flatSmartPointDTO[]before theisMalformed()guard. Belt-and-suspenders guard remains at:178-181.[NOTE — by design, commit
5128ad7d5] Five transporter class names have no entry in$config['smartPoints']and will always return409 no_providerfromGET /rest/transporter/{id}/smart-point:CYPRUSPOST,DAILYCOURIER,ASAP,DHL, andTAXYDEMA. These are intentional omissions — they are cost-calculation/home-delivery carriers with no pickup-point provider class.TAXYDEMAis superseded byTAXYDEMAV2. Behavior is consistent with legacyAdv_order::smartPointsInitialize(). Documented inline atapplication/config/app.php:449-458.[NOTE]
getPickingPoints()terminology in legacy docs/code refers to the same underlying operation asfetch()inSmartPointsInterface.php:20. The REST endpoint surfaces the modern interface name.[OPEN — minor] The OpenAPI tag description for the smart-point controller still says "guest-accessible, uncached" (
src/Rest/Transporter/Controllers/SmartPoint.php:31), contradicting the 10-minute catalog cache. See AD-06 Transporter Admin §Known Issues.
Tests
tests/Integration/Domains/Transporter/SmartPointCatalog/ServiceTest.phpcovers nested-shape normalization and provider-map assertions (commit34e9d7620, #270).tests/Unit/Domains/Transporter/ExternalRates/DhlRatesServiceTest.phpandAsapServicesServiceTest.phpprovide unit coverage for the external-rate services. No live-API integration tests exist for DHL/ASAP.tests/Unit/Rest/Transporter/Controllers/SmartPointTest.php,tests/Unit/Rest/Transporter/Resources/SmartPoint/ResourceTest.php— unit coverage for the smart-point REST surface.
Related Flows
- AD-06 Transporter Admin — credential/pricing configuration
- AD-03 Order Management — voucher lifecycle (create/close/cancel)
- CF-06 Order Preview — transporter selection at checkout; carrier data persistence after placement