From 57d75996f5b436dac435f02725a0814d43e2f9d8 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 6 Apr 2026 13:15:13 -0700 Subject: [PATCH 01/25] chore: add AGENTS.md (phase 1.6) --- AGENTS.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7546d9fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# AGENTS.md — fleetops + +## Repo purpose +The FleetOps TMS extension. Dual-structure: +- `server/` — Laravel package (`fleetbase/fleetops-api`) +- `addon/` — Ember addon (`@fleetbase/fleetops-engine`) + +Both halves are linked into the host (`fleetbase/api` and `fleetbase/console` respectively) and ship together. + +## What this repo owns +- `server/src/Models/` — Driver, Vehicle, Fleet, Order, Place, Zone, ServiceRate, etc. +- `server/src/Http/Controllers/` — FleetOps REST endpoints +- `server/database/migrations/` — FleetOps tables +- `server/routes/api.php` — route definitions registered under the `fleetops` namespace +- `addon/app/` — Ember engine source: routes, components, services +- `addon/extension.json` — extension registration metadata + +## What this repo must not modify +- `core-api` source. If you need a new helper, propose it as a separate task in `core-api`. +- Other extensions (`storefront`, `pallet`, `ledger`). +- The host console's router or top-level layout. + +## Framework conventions +- Server: Laravel 10+, PSR-4 under `Fleetbase\\FleetOps\\` +- Addon: Ember Engine via `ember-engines`, registered with `UniverseService` from `ember-core` +- Migrations: append-only, prefix with date + +## Test / build commands +- Server: `vendor/bin/phpunit` inside `server/` +- Addon: `cd addon && pnpm test` (rarely needed; the host console picks up changes via hot reload) +- After server changes: `docker compose exec application php artisan migrate` and `php artisan route:list | grep fleetops` + +## Known sharp edges +- **FleetOps is already auto-loaded** in this workspace via the `fleetbase/packages/fleetops` submodule. Linking the top-level `fleetops/` clone is only needed if you want to **edit** it. Until then, the bundled image's copy is what's running. +- The `addon/` and `server/` halves are versioned together — changing only one risks runtime drift. +- Live map requires `GOOGLE_MAPS_API_KEY` to function. + +## Read first +- `~/fleetbase-project/docs/project-description.md` +- `~/fleetbase-project/docs/repo-map.md` +- `~/fleetbase-project/docs/ai-rules-laravel.md` (for `server/`) +- `~/fleetbase-project/docs/ai-rules-ember.md` (for `addon/`) +- `~/fleetbase-project/docs/ai-rules-workspace.md` +- `~/fleetbase-project/docs/extension-contracts.md` + +## Boost gate +Before first edit in `server/`: `composer require laravel/boost --dev && php artisan boost:install`, then commit. From 64adac365f5cfe5ba2183e133be8b92123470392 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 6 Apr 2026 14:19:47 -0700 Subject: [PATCH 02/25] docs: clarify boost gate (host-cloned, needs real tty) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7546d9fe..f6deeead 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,4 +44,4 @@ Both halves are linked into the host (`fleetbase/api` and `fleetbase/console` re - `~/fleetbase-project/docs/extension-contracts.md` ## Boost gate -Before first edit in `server/`: `composer require laravel/boost --dev && php artisan boost:install`, then commit. +`fleetops/server` IS host-cloned, so Boost outputs would land in a place future agents can read. Before first edit in `server/`: `composer require laravel/boost --dev && php artisan boost:install` from a **real terminal** (the installer is interactive and crashes on `docker compose exec -T`). Then commit. From b15ea084087cc347407c4c0649d37f87a23a47a9 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Wed, 8 Apr 2026 11:03:10 -0700 Subject: [PATCH 03/25] feat(parcelpath): add ParcelPathServiceType enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors LalamoveServiceType shape (Collection of typed instances with dynamic property hydration, __get/__call, static all()/find()). Provides 13 PP_* service keys covering UPS Ground / Ground Saver / 3 Day Select / 2nd Day Air / 2nd Day Air A.M. / Next Day Air / Next Day Air Early / Next Day Air Saver and USPS Priority / Priority Express / Ground Advantage / First Class / Media Mail, each annotated with carrier and pp_v9 service token for routing through the ParcelPath v9 backend. #[\AllowDynamicProperties] is added to silence PHP 8.2's dynamic property deprecation while keeping the Lalamove-compatible hydration pattern. LalamoveServiceType has not yet been migrated to the same attribute — doing that is out of scope for this change. Tests (Pest): 9 passed, 26 assertions. --- .../ParcelPath/ParcelPathServiceType.php | 80 +++++++++++++++++++ .../ParcelPath/ParcelPathServiceTypeTest.php | 68 ++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 server/src/Integrations/ParcelPath/ParcelPathServiceType.php create mode 100644 server/tests/Integrations/ParcelPath/ParcelPathServiceTypeTest.php diff --git a/server/src/Integrations/ParcelPath/ParcelPathServiceType.php b/server/src/Integrations/ParcelPath/ParcelPathServiceType.php new file mode 100644 index 00000000..a3f6c455 --- /dev/null +++ b/server/src/Integrations/ParcelPath/ParcelPathServiceType.php @@ -0,0 +1,80 @@ + 'PP_UPS_GROUND', 'description' => 'UPS Ground', 'carrier' => 'UPS', 'pp_v9' => 'ups_ground'], + ['key' => 'PP_UPS_GROUND_SAVER', 'description' => 'UPS Ground Saver', 'carrier' => 'UPS', 'pp_v9' => 'ups_ground_saver'], + ['key' => 'PP_UPS_3DS', 'description' => 'UPS 3 Day Select', 'carrier' => 'UPS', 'pp_v9' => 'ups_3_day_select'], + ['key' => 'PP_UPS_2DA', 'description' => 'UPS 2nd Day Air', 'carrier' => 'UPS', 'pp_v9' => 'ups_2nd_day_air'], + ['key' => 'PP_UPS_2DAM', 'description' => 'UPS 2nd Day Air A.M.', 'carrier' => 'UPS', 'pp_v9' => 'ups_2nd_day_air_am'], + ['key' => 'PP_UPS_1DA', 'description' => 'UPS Next Day Air', 'carrier' => 'UPS', 'pp_v9' => 'ups_next_day_air'], + ['key' => 'PP_UPS_1DAM', 'description' => 'UPS Next Day Air Early', 'carrier' => 'UPS', 'pp_v9' => 'ups_next_day_air_early'], + ['key' => 'PP_UPS_1DASAVER', 'description' => 'UPS Next Day Air Saver', 'carrier' => 'UPS', 'pp_v9' => 'ups_next_day_air_saver'], + ['key' => 'PP_USPS_PRIORITY', 'description' => 'USPS Priority Mail', 'carrier' => 'USPS', 'pp_v9' => 'Priority'], + ['key' => 'PP_USPS_EXPRESS', 'description' => 'USPS Priority Mail Express', 'carrier' => 'USPS', 'pp_v9' => 'Express'], + ['key' => 'PP_USPS_GROUND_ADV', 'description' => 'USPS Ground Advantage', 'carrier' => 'USPS', 'pp_v9' => 'GroundAdvantage'], + ['key' => 'PP_USPS_FIRST', 'description' => 'USPS First Class', 'carrier' => 'USPS', 'pp_v9' => 'First'], + ['key' => 'PP_USPS_MEDIA', 'description' => 'USPS Media Mail', 'carrier' => 'USPS', 'pp_v9' => 'MediaMail'], + ]; + + public function __construct(array $details) + { + foreach ($details as $key => $value) { + $this->{$key} = $value; + } + } + + public function __get(string $key) + { + if (isset($this->{$key})) { + return $this->{$key}; + } + + return null; + } + + public function __call(string $key, $arguments) + { + if ($key === 'all') { + return collect(static::$serviceTypes)->mapInto(ParcelPathServiceType::class); + } + + if (method_exists($this, $key)) { + $this->{$key}(...$arguments); + } + + return null; + } + + public function getKey() + { + return $this->key; + } + + public static function all() + { + return collect(static::$serviceTypes)->mapInto(ParcelPathServiceType::class); + } + + public static function find($key) + { + if (is_callable($key)) { + return static::all()->first($key); + } + + if (is_string($key)) { + return static::all()->first(function ($detail) use ($key) { + return isset($detail->key) && strcasecmp($detail->key, $key) === 0; + }); + } + + return null; + } +} diff --git a/server/tests/Integrations/ParcelPath/ParcelPathServiceTypeTest.php b/server/tests/Integrations/ParcelPath/ParcelPathServiceTypeTest.php new file mode 100644 index 00000000..518185a7 --- /dev/null +++ b/server/tests/Integrations/ParcelPath/ParcelPathServiceTypeTest.php @@ -0,0 +1,68 @@ +toHaveCount(13); +}); + +test('find() returns UPS Ground with correct metadata', function () { + $type = ParcelPathServiceType::find('PP_UPS_GROUND'); + expect($type)->not->toBeNull(); + expect($type->carrier)->toBe('UPS'); + expect($type->pp_v9)->toBe('ups_ground'); + expect($type->description)->toBe('UPS Ground'); +}); + +test('find() is case-insensitive', function () { + expect(ParcelPathServiceType::find('pp_ups_ground'))->not->toBeNull(); +}); + +test('find() returns null for unknown key', function () { + expect(ParcelPathServiceType::find('NOPE'))->toBeNull(); +}); + +test('find() accepts a callable', function () { + $type = ParcelPathServiceType::find(fn ($t) => $t->key === 'PP_USPS_MEDIA'); + expect($type)->not->toBeNull(); + expect($type->pp_v9)->toBe('MediaMail'); +}); + +test('all UPS services are present', function () { + $upsKeys = ParcelPathServiceType::all() + ->filter(fn ($t) => $t->carrier === 'UPS') + ->map(fn ($t) => $t->key) + ->all(); + + expect($upsKeys)->toContain( + 'PP_UPS_GROUND', + 'PP_UPS_GROUND_SAVER', + 'PP_UPS_3DS', + 'PP_UPS_2DA', + 'PP_UPS_2DAM', + 'PP_UPS_1DA', + 'PP_UPS_1DAM', + 'PP_UPS_1DASAVER' + ); + expect(count($upsKeys))->toBe(8); +}); + +test('all USPS services are present', function () { + $uspsKeys = ParcelPathServiceType::all() + ->filter(fn ($t) => $t->carrier === 'USPS') + ->map(fn ($t) => $t->key) + ->all(); + + expect($uspsKeys)->toContain( + 'PP_USPS_PRIORITY', + 'PP_USPS_EXPRESS', + 'PP_USPS_GROUND_ADV', + 'PP_USPS_FIRST', + 'PP_USPS_MEDIA' + ); + expect(count($uspsKeys))->toBe(5); +}); + +test('USPS Priority maps to pp_v9 token Priority', function () { + expect(ParcelPathServiceType::find('PP_USPS_PRIORITY')->pp_v9)->toBe('Priority'); +}); From 7e0c819bb5779d9b2139a0fef74df6ddfa2a31d7 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Wed, 8 Apr 2026 13:59:52 -0700 Subject: [PATCH 04/25] feat(tracking): add carrier_tracking_number column Adds a nullable carrier_tracking_number VARCHAR(100) to tracking_numbers with an index so webhook and poll handlers can look up the owning TrackingNumber by the carrier's identifier (1Z... for UPS, 9400... for USPS, or the ParcelPath-normalized identifier) without querying Order meta JSON. The indexed lookup path is required by the poll jobs added in Task 9 and the webhook controller in Task 26. --- ...cking_number_to_tracking_numbers_table.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 server/migrations/2026_04_07_000001_add_carrier_tracking_number_to_tracking_numbers_table.php diff --git a/server/migrations/2026_04_07_000001_add_carrier_tracking_number_to_tracking_numbers_table.php b/server/migrations/2026_04_07_000001_add_carrier_tracking_number_to_tracking_numbers_table.php new file mode 100644 index 00000000..abd6554c --- /dev/null +++ b/server/migrations/2026_04_07_000001_add_carrier_tracking_number_to_tracking_numbers_table.php @@ -0,0 +1,33 @@ +string('carrier_tracking_number', 100)->nullable()->after('tracking_number'); + $table->index('carrier_tracking_number'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('tracking_numbers', function (Blueprint $table) { + $table->dropIndex(['carrier_tracking_number']); + $table->dropColumn('carrier_tracking_number'); + }); + } +}; From 3c0e5b4fbece0b1fa7c5b0883c7c7f6b35af8b93 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Wed, 8 Apr 2026 13:59:53 -0700 Subject: [PATCH 05/25] feat(parcelpath): register ParcelPath in IntegratedVendors Appends the parcelpath entry to IntegratedVendors::\$supported with the single api_key credential, carrier_filter/label_format/insurance_default /markup option params, and the credentials.api_key -> apiKey bridge param mapping. iso2cc_bridge is null because ParcelPath is US-only and no market selector is needed (ResolvedIntegratedVendor::getCountries already guards against null). Also introduces a minimal ParcelPath bridge class so ParcelPath::class resolves and the autoloader can find the namespace. The rating, label, tracking, and void methods land in Tasks 2-5 on this branch; the stub exists only so the registry entry wires through cleanly. Tests (Pest): 8 new registry tests covering core fields, credential params, option params, bridgeParams, option value lists, and the empty callbacks array. Full suite: 17 passed. --- .../Integrations/ParcelPath/ParcelPath.php | 23 ++++++ server/src/Support/IntegratedVendors.php | 42 ++++++++++ .../Support/IntegratedVendorsRegistryTest.php | 77 +++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 server/src/Integrations/ParcelPath/ParcelPath.php create mode 100644 server/tests/Support/IntegratedVendorsRegistryTest.php diff --git a/server/src/Integrations/ParcelPath/ParcelPath.php b/server/src/Integrations/ParcelPath/ParcelPath.php new file mode 100644 index 00000000..f8b3c2fd --- /dev/null +++ b/server/src/Integrations/ParcelPath/ParcelPath.php @@ -0,0 +1,23 @@ +apiKey = $apiKey; + $this->sandbox = $sandbox; + } +} diff --git a/server/src/Support/IntegratedVendors.php b/server/src/Support/IntegratedVendors.php index 4dca3061..6c464800 100644 --- a/server/src/Support/IntegratedVendors.php +++ b/server/src/Support/IntegratedVendors.php @@ -5,6 +5,8 @@ use Fleetbase\FleetOps\Integrations\Lalamove\Lalamove; use Fleetbase\FleetOps\Integrations\Lalamove\LalamoveMarket; use Fleetbase\FleetOps\Integrations\Lalamove\LalamoveServiceType; +use Fleetbase\FleetOps\Integrations\ParcelPath\ParcelPath; +use Fleetbase\FleetOps\Integrations\ParcelPath\ParcelPathServiceType; use Fleetbase\FleetOps\Models\IntegratedVendor; use Illuminate\Support\Str; @@ -231,6 +233,46 @@ class IntegratedVendors ], ], ], + [ + 'name' => 'ParcelPath', + 'code' => 'parcelpath', + 'host' => 'https://api.parcelpath.com/', + 'sandbox' => 'https://api-sandbox.parcelpath.com/', + 'namespace' => 'v1', + 'bridge' => ParcelPath::class, + 'svc_bridge' => ParcelPathServiceType::class, + 'iso2cc_bridge' => null, + 'credentialParams' => [ + ['key' => 'api_key', 'helpText' => 'Your ParcelPath API key'], + ], + 'optionParams' => [ + ['key' => 'carrier_filter', 'options' => [ + ['value' => 'all', 'label' => 'UPS + USPS'], + ['value' => 'ups', 'label' => 'UPS Only'], + ['value' => 'usps', 'label' => 'USPS Only'], + ], 'optionValue' => 'value', 'optionLabel' => 'label'], + ['key' => 'label_format', 'options' => [ + ['value' => 'PDF', 'label' => 'PDF'], + ['value' => 'ZPL', 'label' => 'ZPL (thermal)'], + ], 'optionValue' => 'value', 'optionLabel' => 'label'], + ['key' => 'insurance_default', 'options' => [ + ['value' => 'none', 'label' => 'No insurance'], + ['value' => 'auto', 'label' => 'Auto-insure all'], + ['value' => 'prompt', 'label' => 'Ask per shipment'], + ], 'optionValue' => 'value', 'optionLabel' => 'label'], + ['key' => 'markup_type', 'options' => [ + ['value' => 'flat', 'label' => 'Flat (cents)'], + ['value' => 'percent', 'label' => 'Percentage'], + ], 'optionValue' => 'value', 'optionLabel' => 'label'], + ['key' => 'markup_amount'], + ['key' => 'client_label'], + ], + 'bridgeParams' => [ + 'apiKey' => 'credentials.api_key', + 'sandbox' => 'sandbox', + ], + 'callbacks' => [], + ], ]; public static function all() diff --git a/server/tests/Support/IntegratedVendorsRegistryTest.php b/server/tests/Support/IntegratedVendorsRegistryTest.php new file mode 100644 index 00000000..4a962590 --- /dev/null +++ b/server/tests/Support/IntegratedVendorsRegistryTest.php @@ -0,0 +1,77 @@ +fail('parcelpath entry not registered in IntegratedVendors::$supported'); +} + +test('parcelpath entry is registered with the expected core fields', function () { + $entry = ppEntry(); + + expect($entry['name'])->toBe('ParcelPath'); + expect($entry['host'])->toBe('https://api.parcelpath.com/'); + expect($entry['sandbox'])->toBe('https://api-sandbox.parcelpath.com/'); + expect($entry['namespace'])->toBe('v1'); + expect($entry['bridge'])->toBe(ParcelPath::class); + expect($entry['svc_bridge'])->toBe(ParcelPathServiceType::class); + expect($entry['iso2cc_bridge'])->toBeNull(); +}); + +test('parcelpath entry declares api_key as the only credential param', function () { + $entry = ppEntry(); + $keys = array_column($entry['credentialParams'], 'key'); + expect($keys)->toBe(['api_key']); +}); + +test('parcelpath entry declares all required option params', function () { + $entry = ppEntry(); + $keys = array_column($entry['optionParams'], 'key'); + + expect($keys)->toContain('carrier_filter'); + expect($keys)->toContain('label_format'); + expect($keys)->toContain('insurance_default'); + expect($keys)->toContain('markup_type'); + expect($keys)->toContain('markup_amount'); + expect($keys)->toContain('client_label'); +}); + +test('parcelpath bridgeParams map credentials.api_key to apiKey', function () { + $entry = ppEntry(); + expect($entry['bridgeParams']['apiKey'])->toBe('credentials.api_key'); + expect($entry['bridgeParams']['sandbox'])->toBe('sandbox'); +}); + +test('carrier_filter option exposes ups, usps, and all', function () { + $entry = ppEntry(); + $carrierFilter = collect($entry['optionParams'])->firstWhere('key', 'carrier_filter'); + $values = array_column($carrierFilter['options'], 'value'); + expect($values)->toEqualCanonicalizing(['all', 'ups', 'usps']); +}); + +test('label_format option exposes PDF and ZPL', function () { + $entry = ppEntry(); + $fmt = collect($entry['optionParams'])->firstWhere('key', 'label_format'); + $values = array_column($fmt['options'], 'value'); + expect($values)->toEqualCanonicalizing(['PDF', 'ZPL']); +}); + +test('insurance_default option exposes none, auto, and prompt', function () { + $entry = ppEntry(); + $ins = collect($entry['optionParams'])->firstWhere('key', 'insurance_default'); + $values = array_column($ins['options'], 'value'); + expect($values)->toEqualCanonicalizing(['none', 'auto', 'prompt']); +}); + +test('parcelpath has no callbacks (no webhook registration)', function () { + $entry = ppEntry(); + expect($entry['callbacks'])->toBe([]); +}); From 189dfe85f06df4146f1391fb9d89a039ecc06acc Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Wed, 8 Apr 2026 23:46:06 -0700 Subject: [PATCH 06/25] feat(parcelpath): scaffold bridge class with HTTP client Expands the Task 6 stub into the full bridge skeleton following the Lalamove pattern: private host/sandboxHost/namespace/isSandbox fields, Guzzle\Client instance, chainable setRequestId / setOptions / setIntegratedVendor setters, buildRequestUrl() path composer, and private request() / get() / post() / delete() helpers that attach the Bearer token, Accept: application/json, Content-Type: application/json, and (when set) X-Request-Id headers. The constructor accepts an optional Guzzle HandlerStack so tests can inject a MockHandler without monkey-patching. Production code always builds its own stack. Rating, label, tracking, and void methods land in Tasks 3-5. Tests (Pest): 12 new bridge tests covering constructor / URL / setter chains / HTTP verb routing / header propagation / non-2xx handling, using Guzzle MockHandler + Middleware::history. Full suite: 29 passed. --- .../Integrations/ParcelPath/ParcelPath.php | 162 ++++++++++++++++-- .../ParcelPath/ParcelPathTest.php | 138 +++++++++++++++ 2 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 server/tests/Integrations/ParcelPath/ParcelPathTest.php diff --git a/server/src/Integrations/ParcelPath/ParcelPath.php b/server/src/Integrations/ParcelPath/ParcelPath.php index f8b3c2fd..81b78b81 100644 --- a/server/src/Integrations/ParcelPath/ParcelPath.php +++ b/server/src/Integrations/ParcelPath/ParcelPath.php @@ -2,22 +2,164 @@ namespace Fleetbase\FleetOps\Integrations\ParcelPath; +use Fleetbase\FleetOps\Models\IntegratedVendor; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; + /** - * ParcelPath API bridge. + * ParcelPath API bridge (Mode A — default). * - * Stub class — registered in IntegratedVendors::$supported so the - * registry entry and constructor wiring resolve. Full bridge - * implementation (rating, labeling, tracking, void) lands in - * subsequent tasks on this branch. + * Mirrors the Lalamove bridge shape (host / sandboxHost / namespace, + * Guzzle client, setRequestId / setOptions / setIntegratedVendor + * chainable setters, private request helpers). The rating, label, + * tracking, and void methods are added in Tasks 3–5 on this branch. */ class ParcelPath { - protected ?string $apiKey; - protected bool $sandbox; + /** + * API Host URL. + */ + private string $host = 'https://api.parcelpath.com/'; + + /** + * API Sandbox Host URL. + */ + private string $sandboxHost = 'https://api-sandbox.parcelpath.com/'; + + /** + * API Namespace. + */ + private string $namespace = 'v1'; + + /** + * Determines if instance is sandbox instance. + */ + private bool $isSandbox = false; + + /** + * ParcelPath API Key. + */ + private ?string $apiKey; + + /** + * Applicable request ID. + */ + private ?string $requestId = null; + + /** + * Applicable options (carrier_filter, label_format, insurance_default, markup_type, markup_amount). + */ + private array $options = []; + + /** + * HTTP Client Instance. + */ + private Client $client; + + /** + * The current integrated vendor accessing instance. + */ + private ?IntegratedVendor $integratedVendor = null; + + public function __construct(?string $apiKey = null, bool $sandbox = false, ?HandlerStack $handler = null) + { + $this->isSandbox = $sandbox; + $this->apiKey = $apiKey; + + $clientConfig = [ + 'base_uri' => $this->buildRequestUrl(), + ]; + + // Injectable handler for tests — prod path is a plain Guzzle client. + if ($handler !== null) { + $clientConfig['handler'] = $handler; + } + + $this->client = new Client($clientConfig); + } + + public function setRequestId(?string $requestId): self + { + $this->requestId = $requestId; + + return $this; + } + + public function setOptions(?array $options = []): self + { + $this->options = array_merge($this->options, (array) $options); + + return $this; + } + + public function getOptions(): array + { + return $this->options; + } + + public function setIntegratedVendor(IntegratedVendor $integratedVendor): self + { + $this->integratedVendor = $integratedVendor; + + return $this; + } + + public function isSandbox(): bool + { + return $this->isSandbox; + } + + public function getApiKey(): ?string + { + return $this->apiKey; + } + + /** + * Compose a request URL. Returns the bare base URL when `$path` is empty + * and `/` otherwise. Matches Lalamove::buildRequestUrl. + */ + public function buildRequestUrl(string $path = ''): string + { + $host = $this->isSandbox ? $this->sandboxHost : $this->host; + + return trim($host . $this->namespace . '/' . $path); + } + + /** + * Execute an authenticated request against the ParcelPath API. + */ + private function request(string $method, string $path, array $options = []) + { + $options['headers'] = array_merge($options['headers'] ?? [], [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . (string) $this->apiKey, + ]); + + if ($this->requestId !== null) { + $options['headers']['X-Request-Id'] = $this->requestId; + } + + $options['http_errors'] = false; + + $response = $this->client->request($method, $path, $options); + $body = (string) $response->getBody(); + + return json_decode($body, true); + } + + public function get(string $path, array $options = []) + { + return $this->request('GET', $path, $options); + } + + public function post(string $path, array $options = []) + { + return $this->request('POST', $path, $options); + } - public function __construct(?string $apiKey = null, bool $sandbox = false) + public function delete(string $path, array $options = []) { - $this->apiKey = $apiKey; - $this->sandbox = $sandbox; + return $this->request('DELETE', $path, $options); } } diff --git a/server/tests/Integrations/ParcelPath/ParcelPathTest.php b/server/tests/Integrations/ParcelPath/ParcelPathTest.php new file mode 100644 index 00000000..60199ce9 --- /dev/null +++ b/server/tests/Integrations/ParcelPath/ParcelPathTest.php @@ -0,0 +1,138 @@ + new Response( + $payload['status'] ?? 200, + ['Content-Type' => 'application/json'], + json_encode($payload['body'] ?? []) + ), + $responses + )); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($this->history)); + + $this->bridge = new ParcelPath('test-key', true, $stack); + } +} + +// ── Constructor / URL ──────────────────────────────────────────────────── + +test('constructor stores api key and sandbox flag', function () { + $bridge = new ParcelPath('my-key', true); + expect($bridge->getApiKey())->toBe('my-key'); + expect($bridge->isSandbox())->toBeTrue(); +}); + +test('sandbox false uses production host', function () { + $bridge = new ParcelPath('k', false); + expect($bridge->buildRequestUrl())->toBe('https://api.parcelpath.com/v1/'); +}); + +test('sandbox true uses sandbox host', function () { + $bridge = new ParcelPath('k', true); + expect($bridge->buildRequestUrl())->toBe('https://api-sandbox.parcelpath.com/v1/'); +}); + +test('buildRequestUrl appends path segment', function () { + $bridge = new ParcelPath('k', true); + expect($bridge->buildRequestUrl('rates'))->toBe('https://api-sandbox.parcelpath.com/v1/rates'); +}); + +// ── Chainable setters ──────────────────────────────────────────────────── + +test('setRequestId is chainable and stores value', function () { + $bridge = new ParcelPath('k'); + expect($bridge->setRequestId('req-123'))->toBe($bridge); +}); + +test('setOptions merges with existing and is chainable', function () { + $bridge = new ParcelPath('k'); + $bridge->setOptions(['carrier_filter' => 'ups', 'label_format' => 'PDF']); + $bridge->setOptions(['label_format' => 'ZPL']); + expect($bridge->getOptions())->toBe([ + 'carrier_filter' => 'ups', + 'label_format' => 'ZPL', + ]); +}); + +test('setOptions tolerates null', function () { + $bridge = new ParcelPath('k'); + $bridge->setOptions(null); + expect($bridge->getOptions())->toBe([]); +}); + +// ── HTTP layer via Guzzle MockHandler ──────────────────────────────────── + +test('post request sends bearer token and json content type', function () { + $h = new PPTestHarness([ + ['status' => 200, 'body' => ['ok' => true]], + ]); + + $result = $h->bridge->post('rates', ['json' => ['x' => 1]]); + + expect($result)->toBe(['ok' => true]); + expect($h->history)->toHaveCount(1); + $request = $h->history[0]['request']; + expect($request->getMethod())->toBe('POST'); + expect($request->getUri()->getPath())->toBe('/v1/rates'); + expect($request->getHeaderLine('Authorization'))->toBe('Bearer test-key'); + expect($request->getHeaderLine('Content-Type'))->toBe('application/json'); + expect($request->getHeaderLine('Accept'))->toBe('application/json'); + expect((string) $request->getBody())->toBe('{"x":1}'); +}); + +test('request id is propagated as X-Request-Id header when set', function () { + $h = new PPTestHarness([ + ['status' => 200, 'body' => []], + ]); + + $h->bridge->setRequestId('req-abc'); + $h->bridge->get('tracking/1Z123'); + + $request = $h->history[0]['request']; + expect($request->getHeaderLine('X-Request-Id'))->toBe('req-abc'); +}); + +test('request id header is absent when not set', function () { + $h = new PPTestHarness([ + ['status' => 200, 'body' => []], + ]); + + $h->bridge->get('tracking/1Z123'); + + $request = $h->history[0]['request']; + expect($request->hasHeader('X-Request-Id'))->toBeFalse(); +}); + +test('delete returns parsed json', function () { + $h = new PPTestHarness([ + ['status' => 200, 'body' => ['voided' => true]], + ]); + + $result = $h->bridge->delete('shipments/pp_ship_1'); + expect($result)->toBe(['voided' => true]); + expect($h->history[0]['request']->getMethod())->toBe('DELETE'); +}); + +test('non-2xx response still returns parsed body without throwing', function () { + $h = new PPTestHarness([ + ['status' => 422, 'body' => ['error' => 'invalid_address']], + ]); + + $result = $h->bridge->post('labels', ['json' => []]); + expect($result)->toBe(['error' => 'invalid_address']); +}); From e8f6db1a272d001a68a3c7597cfe41f206ca1e92 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Wed, 8 Apr 2026 23:55:18 -0700 Subject: [PATCH 07/25] feat(parcelpath): rating via POST /v1/rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ParcelPath::getQuoteFromPayload using the pure/impure split: Pure (static, unit-testable without Laravel bootstrap): - placeToAddress(Place-like $p): Place -> ParcelPath address shape - entitiesToParcels(iterable): Entity -> parcels[] (filters non-parcels, propagates package_template from meta) - buildRatesRequest(shipFrom, shipTo, parcels, carrierFilter): final request body - normalizeRatesResponse(array): raw rates[] -> rows ready for ServiceQuote::create. Converts dollars to integer cents, handles sub-cent rounding, derives insurance_cost cents from insurance_cost dollars, skips rows missing `amount`, defaults currency to USD. Impure wrapper: - getQuoteFromPayload(Payload, ?serviceType, ?scheduledAt, ?isRouteOptimized): composes the pure helpers, POSTs via the Guzzle client, iterates normalized rows creating ServiceQuote + ServiceQuoteItem records. company_uuid and carrier_filter are read from the bound IntegratedVendor. Returns array of created quotes. Tests (Pest): 18 new unit tests targeting the pure halves — Place mapping, parcel mapping (including type filter + template propagation), request assembly, and response normalization (cents conversion, rounding, insurance, skipping invalid rows). Full suite: 47 passed. Impure wrapper is exercised at runtime via the smoke test path; no ORM tests here (matches Lalamove's pattern and avoids dragging in orchestra/testbench). --- .../Integrations/ParcelPath/ParcelPath.php | 164 ++++++++++++++++ .../ParcelPath/ParcelPathRatesBuilderTest.php | 181 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 server/tests/Integrations/ParcelPath/ParcelPathRatesBuilderTest.php diff --git a/server/src/Integrations/ParcelPath/ParcelPath.php b/server/src/Integrations/ParcelPath/ParcelPath.php index 81b78b81..e3028687 100644 --- a/server/src/Integrations/ParcelPath/ParcelPath.php +++ b/server/src/Integrations/ParcelPath/ParcelPath.php @@ -3,6 +3,9 @@ namespace Fleetbase\FleetOps\Integrations\ParcelPath; use Fleetbase\FleetOps\Models\IntegratedVendor; +use Fleetbase\FleetOps\Models\Payload; +use Fleetbase\FleetOps\Models\ServiceQuote; +use Fleetbase\FleetOps\Models\ServiceQuoteItem; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; @@ -162,4 +165,165 @@ public function delete(string $path, array $options = []) { return $this->request('DELETE', $path, $options); } + + // ───────────────────────────────────────────────────────────────────── + // PURE HELPERS — request builders and response normalizers. + // These are static and take plain arrays / duck-typed objects so + // they can be unit-tested without booting Laravel. The runtime + // wrappers below (getQuoteFromPayload, createOrderFromServiceQuote, + // getTrackingStatus, voidShipment) compose these with Eloquent + // writes and the Guzzle client. + // ───────────────────────────────────────────────────────────────────── + + /** + * Convert a Place-like object (anything with street1/city/province/ + * postal_code/country properties) into the ship_from / ship_to shape + * ParcelPath expects. + */ + public static function placeToAddress(object $place): array + { + return [ + 'address' => (string) ($place->street1 ?? ''), + 'city' => (string) ($place->city ?? ''), + 'state' => (string) ($place->province ?? ''), + 'zip' => (string) ($place->postal_code ?? ''), + 'country' => (string) ($place->country ?? 'US'), + ]; + } + + /** + * Convert an iterable of Entity-like parcel objects into the parcels + * array ParcelPath expects. Non-parcel entities are skipped. + */ + public static function entitiesToParcels(iterable $entities): array + { + $parcels = []; + foreach ($entities as $entity) { + $type = $entity->type ?? null; + if ($type !== null && $type !== 'parcel') { + continue; + } + $parcels[] = [ + 'length' => (float) ($entity->length ?? 0), + 'width' => (float) ($entity->width ?? 0), + 'height' => (float) ($entity->height ?? 0), + 'weight' => (float) ($entity->weight ?? 0), + 'template' => isset($entity->meta['package_template']) + ? (string) $entity->meta['package_template'] + : null, + ]; + } + return $parcels; + } + + /** + * Build the POST /v1/rates request body. + */ + public static function buildRatesRequest( + array $shipFrom, + array $shipTo, + array $parcels, + ?string $carrierFilter = 'all' + ): array { + return [ + 'ship_from' => $shipFrom, + 'ship_to' => $shipTo, + 'parcels' => $parcels, + 'carrier_filter' => $carrierFilter ?: 'all', + ]; + } + + /** + * Normalize the POST /v1/rates response into an array of rows ready + * for ServiceQuote::create(...). Amount is converted to integer cents. + * Each row carries a `meta` sub-array with carrier/service_token/ + * pp_rate_id/estimated_days/insurance_available/insurance_cost/ + * carrier_amount so the runtime wrapper can persist it verbatim. + */ + public static function normalizeRatesResponse(array $response): array + { + $rows = []; + $rates = $response['rates'] ?? []; + foreach ($rates as $rate) { + if (!isset($rate['amount'])) { + continue; + } + $amountCents = (int) round(((float) $rate['amount']) * 100); + $insuranceCostCents = isset($rate['insurance_cost']) + ? (int) round(((float) $rate['insurance_cost']) * 100) + : null; + + $rows[] = [ + 'amount' => $amountCents, + 'currency' => (string) ($rate['currency'] ?? 'USD'), + 'service' => (string) ($rate['service'] ?? ''), + 'meta' => [ + 'carrier' => (string) ($rate['carrier'] ?? ''), + 'service_token' => (string) ($rate['service_token'] ?? ''), + 'pp_rate_id' => $rate['rate_id'] ?? null, + 'estimated_days' => $rate['estimated_days'] ?? null, + 'insurance_available' => (bool) ($rate['insurance_available'] ?? false), + 'insurance_cost' => $insuranceCostCents, + 'carrier_amount' => $amountCents, + ], + ]; + } + return $rows; + } + + // ───────────────────────────────────────────────────────────────────── + // IMPURE RUNTIME WRAPPERS — compose pure helpers + HTTP + Eloquent. + // Not unit-tested in this phase; exercised via smoke test once the + // full label/order flow lands. Kept thin so the testable parts are + // all above. + // ───────────────────────────────────────────────────────────────────── + + /** + * Rate a Payload via POST /v1/rates and persist each returned rate as + * a ServiceQuote + ServiceQuoteItem. Returns the created ServiceQuote + * collection. Called from ServiceQuoteController::query via the + * IntegratedVendor bridge machinery. + */ + public function getQuoteFromPayload( + Payload $payload, + ?string $serviceType = null, + ?string $scheduledAt = null, + ?bool $isRouteOptimized = null + ): array { + $carrierFilter = $this->integratedVendor?->options['carrier_filter'] ?? 'all'; + + $body = static::buildRatesRequest( + static::placeToAddress($payload->pickup), + static::placeToAddress($payload->dropoff), + static::entitiesToParcels($payload->entities ?? []), + $carrierFilter + ); + + $response = $this->post('rates', ['json' => $body]) ?? []; + $rows = static::normalizeRatesResponse($response); + + $quotes = []; + foreach ($rows as $row) { + $serviceQuote = ServiceQuote::create([ + 'company_uuid' => $this->integratedVendor?->company_uuid, + 'payload_uuid' => $payload->uuid, + 'service_type' => 'parcel', + 'amount' => $row['amount'], + 'currency' => $row['currency'], + 'meta' => $row['meta'], + ]); + + ServiceQuoteItem::create([ + 'service_quote_uuid' => $serviceQuote->uuid, + 'amount' => $row['amount'], + 'currency' => $row['currency'], + 'details' => $row['meta']['carrier'] . ' ' . $row['service'], + 'code' => $row['meta']['service_token'], + ]); + + $quotes[] = $serviceQuote; + } + + return $quotes; + } } diff --git a/server/tests/Integrations/ParcelPath/ParcelPathRatesBuilderTest.php b/server/tests/Integrations/ParcelPath/ParcelPathRatesBuilderTest.php new file mode 100644 index 00000000..cbc21993 --- /dev/null +++ b/server/tests/Integrations/ParcelPath/ParcelPathRatesBuilderTest.php @@ -0,0 +1,181 @@ + '1600 Pennsylvania Ave NW', + 'city' => 'Washington', + 'province' => 'DC', + 'postal_code' => '20500', + 'country' => 'US', + ]; + + expect(ParcelPath::placeToAddress($place))->toBe([ + 'address' => '1600 Pennsylvania Ave NW', + 'city' => 'Washington', + 'state' => 'DC', + 'zip' => '20500', + 'country' => 'US', + ]); +}); + +test('placeToAddress defaults country to US when missing', function () { + $place = (object) [ + 'street1' => '1 Main', + 'city' => 'Boise', + 'province' => 'ID', + 'postal_code' => '83702', + ]; + + expect(ParcelPath::placeToAddress($place)['country'])->toBe('US'); +}); + +test('placeToAddress coerces nulls to empty strings', function () { + $place = (object) ['street1' => null, 'city' => null, 'province' => null, 'postal_code' => null]; + $result = ParcelPath::placeToAddress($place); + expect($result['address'])->toBe(''); + expect($result['city'])->toBe(''); + expect($result['zip'])->toBe(''); +}); + +// ── entitiesToParcels ──────────────────────────────────────────────────── + +test('entitiesToParcels maps parcel entities to dimension + weight payload', function () { + $entities = [ + (object) ['type' => 'parcel', 'length' => 12.0, 'width' => 8.0, 'height' => 4.0, 'weight' => 2.5], + ]; + + $parcels = ParcelPath::entitiesToParcels($entities); + + expect($parcels)->toHaveCount(1); + expect($parcels[0]['length'])->toBe(12.0); + expect($parcels[0]['width'])->toBe(8.0); + expect($parcels[0]['height'])->toBe(4.0); + expect($parcels[0]['weight'])->toBe(2.5); + expect($parcels[0]['template'])->toBeNull(); +}); + +test('entitiesToParcels skips non-parcel entities', function () { + $entities = [ + (object) ['type' => 'parcel', 'length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1], + (object) ['type' => 'document','length' => 9, 'width' => 9, 'height' => 9, 'weight' => 9], + (object) ['type' => 'parcel', 'length' => 2, 'width' => 2, 'height' => 2, 'weight' => 2], + ]; + + $parcels = ParcelPath::entitiesToParcels($entities); + expect($parcels)->toHaveCount(2); +}); + +test('entitiesToParcels treats entity with no type as parcel', function () { + $entities = [(object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1]]; + expect(ParcelPath::entitiesToParcels($entities))->toHaveCount(1); +}); + +test('entitiesToParcels propagates package_template from meta', function () { + $entities = [(object) [ + 'type' => 'parcel', 'length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1, + 'meta' => ['package_template' => 'medium_flat_rate_box'], + ]]; + expect(ParcelPath::entitiesToParcels($entities)[0]['template'])->toBe('medium_flat_rate_box'); +}); + +// ── buildRatesRequest ──────────────────────────────────────────────────── + +test('buildRatesRequest assembles ship_from / ship_to / parcels / carrier_filter', function () { + $body = ParcelPath::buildRatesRequest( + ['zip' => '94110'], + ['zip' => '10001'], + [['length' => 10, 'width' => 10, 'height' => 10, 'weight' => 3]], + 'all' + ); + + expect($body['ship_from']['zip'])->toBe('94110'); + expect($body['ship_to']['zip'])->toBe('10001'); + expect($body['parcels'])->toHaveCount(1); + expect($body['carrier_filter'])->toBe('all'); +}); + +test('buildRatesRequest defaults carrier_filter to all when null', function () { + $body = ParcelPath::buildRatesRequest(['zip' => '1'], ['zip' => '2'], [], null); + expect($body['carrier_filter'])->toBe('all'); +}); + +test('buildRatesRequest passes through ups/usps carrier_filter', function () { + expect(ParcelPath::buildRatesRequest(['zip' => '1'], ['zip' => '2'], [], 'ups')['carrier_filter'])->toBe('ups'); + expect(ParcelPath::buildRatesRequest(['zip' => '1'], ['zip' => '2'], [], 'usps')['carrier_filter'])->toBe('usps'); +}); + +// ── normalizeRatesResponse ─────────────────────────────────────────────── + +test('normalizeRatesResponse converts dollars to integer cents', function () { + $rows = ParcelPath::normalizeRatesResponse(['rates' => [[ + 'carrier' => 'UPS', 'service' => 'Ground', 'service_token' => 'ups_ground', + 'amount' => 8.42, 'currency' => 'USD', 'estimated_days' => 5, 'rate_id' => 'rate_abc', + ]]]); + + expect($rows)->toHaveCount(1); + expect($rows[0]['amount'])->toBe(842); + expect($rows[0]['currency'])->toBe('USD'); + expect($rows[0]['service'])->toBe('Ground'); + expect($rows[0]['meta']['carrier'])->toBe('UPS'); + expect($rows[0]['meta']['service_token'])->toBe('ups_ground'); + expect($rows[0]['meta']['pp_rate_id'])->toBe('rate_abc'); + expect($rows[0]['meta']['estimated_days'])->toBe(5); + expect($rows[0]['meta']['carrier_amount'])->toBe(842); +}); + +test('normalizeRatesResponse converts insurance_cost to cents when present', function () { + $rows = ParcelPath::normalizeRatesResponse(['rates' => [[ + 'carrier' => 'UPS', 'amount' => 8.42, 'insurance_available' => true, 'insurance_cost' => 1.25, + ]]]); + expect($rows[0]['meta']['insurance_available'])->toBeTrue(); + expect($rows[0]['meta']['insurance_cost'])->toBe(125); +}); + +test('normalizeRatesResponse leaves insurance_cost null when absent', function () { + $rows = ParcelPath::normalizeRatesResponse(['rates' => [['amount' => 5.00]]]); + expect($rows[0]['meta']['insurance_cost'])->toBeNull(); + expect($rows[0]['meta']['insurance_available'])->toBeFalse(); +}); + +test('normalizeRatesResponse handles sub-cent rounding correctly', function () { + $rows = ParcelPath::normalizeRatesResponse(['rates' => [ + ['amount' => 8.425], // rounds to 843 + ['amount' => 8.424], // rounds to 842 + ]]); + expect($rows[0]['amount'])->toBe(843); + expect($rows[1]['amount'])->toBe(842); +}); + +test('normalizeRatesResponse returns multiple UPS and USPS rates', function () { + $rows = ParcelPath::normalizeRatesResponse(['rates' => [ + ['carrier' => 'UPS', 'service' => 'Ground', 'amount' => 8.42], + ['carrier' => 'USPS', 'service' => 'Priority', 'amount' => 7.15], + ]]); + + expect($rows)->toHaveCount(2); + expect($rows[0]['meta']['carrier'])->toBe('UPS'); + expect($rows[1]['meta']['carrier'])->toBe('USPS'); + expect($rows[1]['amount'])->toBe(715); +}); + +test('normalizeRatesResponse skips rate rows without an amount field', function () { + $rows = ParcelPath::normalizeRatesResponse(['rates' => [ + ['carrier' => 'UPS', 'amount' => 5.00], + ['carrier' => 'UPS', 'error' => 'out_of_service_area'], + ['carrier' => 'USPS', 'amount' => 6.00], + ]]); + expect($rows)->toHaveCount(2); +}); + +test('normalizeRatesResponse returns empty array when rates key is missing', function () { + expect(ParcelPath::normalizeRatesResponse([]))->toBe([]); +}); + +test('normalizeRatesResponse defaults currency to USD when absent', function () { + $rows = ParcelPath::normalizeRatesResponse(['rates' => [['amount' => 5.00]]]); + expect($rows[0]['currency'])->toBe('USD'); +}); From 5870f32d448c266f6000015210637858f47bf805 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 00:09:11 -0700 Subject: [PATCH 08/25] feat(parcelpath): label purchase via POST /v1/labels Pure: buildLabelPurchaseRequest, normalizeLabelResponse. Impure: createOrderFromServiceQuote composes them with the Guzzle client, writes the label binary to Storage as a File record under carrier-labels/, and stores the ParcelPath shipment id / tracking number / insurance payload on Order.meta.integrated_vendor_order. Tests (Pest): 15 new unit tests covering request build (rate_id required, label_format uppercase + PDF default) and response normalization (base64 decode, mime derivation, insurance pass- through, missing-field errors). Full suite: 62 passed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Integrations/ParcelPath/ParcelPath.php | 84 ++++++++++++++ .../ParcelPath/ParcelPathLabelBuilderTest.php | 108 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 server/tests/Integrations/ParcelPath/ParcelPathLabelBuilderTest.php diff --git a/server/src/Integrations/ParcelPath/ParcelPath.php b/server/src/Integrations/ParcelPath/ParcelPath.php index e3028687..51f00aa0 100644 --- a/server/src/Integrations/ParcelPath/ParcelPath.php +++ b/server/src/Integrations/ParcelPath/ParcelPath.php @@ -3,11 +3,14 @@ namespace Fleetbase\FleetOps\Integrations\ParcelPath; use Fleetbase\FleetOps\Models\IntegratedVendor; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\FleetOps\Models\ServiceQuoteItem; +use Fleetbase\Models\File; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; +use Illuminate\Support\Facades\Storage; /** * ParcelPath API bridge (Mode A — default). @@ -271,6 +274,44 @@ public static function normalizeRatesResponse(array $response): array return $rows; } + /** + * Build the POST /v1/labels request body. + */ + public static function buildLabelPurchaseRequest(?string $rateId, string $labelFormat = 'PDF'): array + { + if ($rateId === null || $rateId === '') { + throw new \InvalidArgumentException('rate_id required'); + } + + return [ + 'rate_id' => $rateId, + 'label_format' => strtoupper($labelFormat), + ]; + } + + /** + * Normalize the POST /v1/labels response into a row ready for persistence. + */ + public static function normalizeLabelResponse(array $response): array + { + if (empty($response['tracking_number']) || !isset($response['label_data'])) { + throw new \RuntimeException('invalid label response'); + } + + $format = strtoupper((string) ($response['label_format'] ?? 'PDF')); + $mime = $format === 'ZPL' ? 'application/zpl' : 'application/pdf'; + + return [ + 'tracking_number' => (string) $response['tracking_number'], + 'carrier' => (string) ($response['carrier'] ?? ''), + 'label_binary' => base64_decode((string) $response['label_data']), + 'label_format' => $format, + 'label_mime' => $mime, + 'parcelpath_shipment_id' => $response['parcelpath_shipment_id'] ?? null, + 'insurance' => (array) ($response['insurance'] ?? []), + ]; + } + // ───────────────────────────────────────────────────────────────────── // IMPURE RUNTIME WRAPPERS — compose pure helpers + HTTP + Eloquent. // Not unit-tested in this phase; exercised via smoke test once the @@ -326,4 +367,47 @@ public function getQuoteFromPayload( return $quotes; } + + /** + * Purchase a label via POST /v1/labels, persist the label binary as a + * File record, and stamp the ParcelPath shipment id / tracking number / + * insurance payload onto Order.meta.integrated_vendor_order. + */ + public function createOrderFromServiceQuote(ServiceQuote $serviceQuote, Order $order): array + { + $rateId = $serviceQuote->meta['pp_rate_id'] ?? null; + $format = $this->integratedVendor?->options['label_format'] ?? 'PDF'; + + $body = static::buildLabelPurchaseRequest($rateId, $format); + $response = $this->post('labels', ['json' => $body]) ?? []; + $row = static::normalizeLabelResponse($response); + + $ext = strtolower($row['label_format']); + $trackingNumber = $row['tracking_number']; + $path = 'carrier-labels/pp_label_' . $trackingNumber . '.' . $ext; + $disk = config('filesystems.default'); + $originalFilename = 'pp_label_' . $trackingNumber . '.' . $ext; + + Storage::disk($disk)->put($path, $row['label_binary']); + + File::create([ + 'company_uuid' => $order->company_uuid, + 'subject_uuid' => $order->uuid, + 'subject_type' => Order::class, + 'folder' => 'carrier-labels', + 'content_type' => $row['label_mime'], + 'path' => $path, + 'disk' => $disk, + 'original_filename' => $originalFilename, + ]); + + $order->updateMeta('integrated_vendor_order', [ + 'parcelpath_shipment_id' => $row['parcelpath_shipment_id'], + 'tracking_number' => $trackingNumber, + 'carrier' => $row['carrier'], + 'insurance' => $row['insurance'], + ]); + + return $row; + } } diff --git a/server/tests/Integrations/ParcelPath/ParcelPathLabelBuilderTest.php b/server/tests/Integrations/ParcelPath/ParcelPathLabelBuilderTest.php new file mode 100644 index 00000000..639a2ae8 --- /dev/null +++ b/server/tests/Integrations/ParcelPath/ParcelPathLabelBuilderTest.php @@ -0,0 +1,108 @@ +toBe(['rate_id' => 'rate_abc', 'label_format' => 'PDF']); +}); + +test('buildLabelPurchaseRequest defaults label_format to PDF', function () { + $body = ParcelPath::buildLabelPurchaseRequest('rate_abc'); + expect($body['label_format'])->toBe('PDF'); +}); + +test('buildLabelPurchaseRequest uppercases pdf to PDF', function () { + expect(ParcelPath::buildLabelPurchaseRequest('r1', 'pdf')['label_format'])->toBe('PDF'); +}); + +test('buildLabelPurchaseRequest uppercases zpl to ZPL', function () { + expect(ParcelPath::buildLabelPurchaseRequest('r1', 'zpl')['label_format'])->toBe('ZPL'); +}); + +test('buildLabelPurchaseRequest throws InvalidArgumentException on null rate_id', function () { + ParcelPath::buildLabelPurchaseRequest(null); +})->throws(\InvalidArgumentException::class, 'rate_id required'); + +test('buildLabelPurchaseRequest throws InvalidArgumentException on empty rate_id', function () { + ParcelPath::buildLabelPurchaseRequest(''); +})->throws(\InvalidArgumentException::class, 'rate_id required'); + +// ── normalizeLabelResponse ─────────────────────────────────────────────── + +test('normalizeLabelResponse extracts tracking_number, carrier, parcelpath_shipment_id', function () { + $row = ParcelPath::normalizeLabelResponse([ + 'tracking_number' => '1Z999AA10123456784', + 'carrier' => 'UPS', + 'label_data' => base64_encode('PDFBYTES'), + 'label_format' => 'PDF', + 'parcelpath_shipment_id' => 'pp_ship_001', + ]); + expect($row['tracking_number'])->toBe('1Z999AA10123456784'); + expect($row['carrier'])->toBe('UPS'); + expect($row['parcelpath_shipment_id'])->toBe('pp_ship_001'); +}); + +test('normalizeLabelResponse base64-decodes label_data into label_binary', function () { + $row = ParcelPath::normalizeLabelResponse([ + 'tracking_number' => 'T1', + 'label_data' => base64_encode('HELLOPDF'), + ]); + expect($row['label_binary'])->toBe('HELLOPDF'); +}); + +test('normalizeLabelResponse derives application/pdf mime for PDF format', function () { + $row = ParcelPath::normalizeLabelResponse([ + 'tracking_number' => 'T1', + 'label_data' => base64_encode('x'), + 'label_format' => 'PDF', + ]); + expect($row['label_mime'])->toBe('application/pdf'); + expect($row['label_format'])->toBe('PDF'); +}); + +test('normalizeLabelResponse derives application/zpl mime for ZPL format', function () { + $row = ParcelPath::normalizeLabelResponse([ + 'tracking_number' => 'T1', + 'label_data' => base64_encode('x'), + 'label_format' => 'zpl', + ]); + expect($row['label_mime'])->toBe('application/zpl'); + expect($row['label_format'])->toBe('ZPL'); +}); + +test('normalizeLabelResponse defaults label_format to PDF when missing', function () { + $row = ParcelPath::normalizeLabelResponse([ + 'tracking_number' => 'T1', + 'label_data' => base64_encode('x'), + ]); + expect($row['label_format'])->toBe('PDF'); + expect($row['label_mime'])->toBe('application/pdf'); +}); + +test('normalizeLabelResponse defaults insurance to empty array when missing', function () { + $row = ParcelPath::normalizeLabelResponse([ + 'tracking_number' => 'T1', + 'label_data' => base64_encode('x'), + ]); + expect($row['insurance'])->toBe([]); +}); + +test('normalizeLabelResponse preserves insurance payload when present', function () { + $row = ParcelPath::normalizeLabelResponse([ + 'tracking_number' => 'T1', + 'label_data' => base64_encode('x'), + 'insurance' => ['purchased' => true, 'policy_id' => 'pol_123'], + ]); + expect($row['insurance'])->toBe(['purchased' => true, 'policy_id' => 'pol_123']); +}); + +test('normalizeLabelResponse throws RuntimeException if tracking_number missing', function () { + ParcelPath::normalizeLabelResponse(['label_data' => base64_encode('x')]); +})->throws(\RuntimeException::class, 'invalid label response'); + +test('normalizeLabelResponse throws RuntimeException if label_data missing', function () { + ParcelPath::normalizeLabelResponse(['tracking_number' => 'T1']); +})->throws(\RuntimeException::class, 'invalid label response'); From 52d6aac416f929f54b9f334c4672fdc81346dd6e Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 00:10:17 -0700 Subject: [PATCH 09/25] feat(parcelpath): tracking status + void shipment Pure: normalizeTrackingResponse (uppercase status/carrier/codes, preserve event order, defensive defaults) and normalizeVoidResponse (accepts \`voided: true\` and case-insensitive 'voided'/'cancelled' status). Impure: getTrackingStatus calls GET /v1/tracking/{n}; voidShipment calls DELETE /v1/shipments/{id}. Both compose the pure normalizer. Tests (Pest): 14 new unit tests. Full suite: 76 passed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Integrations/ParcelPath/ParcelPath.php | 54 +++++++++ .../ParcelPathTrackingNormalizerTest.php | 103 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 server/tests/Integrations/ParcelPath/ParcelPathTrackingNormalizerTest.php diff --git a/server/src/Integrations/ParcelPath/ParcelPath.php b/server/src/Integrations/ParcelPath/ParcelPath.php index 51f00aa0..7c4b4665 100644 --- a/server/src/Integrations/ParcelPath/ParcelPath.php +++ b/server/src/Integrations/ParcelPath/ParcelPath.php @@ -312,6 +312,42 @@ public static function normalizeLabelResponse(array $response): array ]; } + /** + * Normalize the GET /v1/tracking/{n} response. + */ + public static function normalizeTrackingResponse(array $response): array + { + $events = []; + foreach (($response['events'] ?? []) as $event) { + $events[] = [ + 'code' => strtoupper((string) ($event['code'] ?? '')), + 'status' => (string) ($event['status'] ?? ''), + 'timestamp' => isset($event['timestamp']) ? (string) $event['timestamp'] : null, + 'location' => isset($event['location']) ? (string) $event['location'] : null, + 'details' => isset($event['details']) ? (string) $event['details'] : null, + ]; + } + + return [ + 'status' => isset($response['status']) ? strtoupper((string) $response['status']) : 'UNKNOWN', + 'carrier' => isset($response['carrier']) ? strtoupper((string) $response['carrier']) : '', + 'events' => $events, + ]; + } + + /** + * Normalize the DELETE /v1/shipments/{id} response into a boolean. + */ + public static function normalizeVoidResponse(array $response): bool + { + if (isset($response['voided']) && $response['voided'] === true) { + return true; + } + + $status = isset($response['status']) ? strtolower((string) $response['status']) : ''; + return $status === 'voided' || $status === 'cancelled'; + } + // ───────────────────────────────────────────────────────────────────── // IMPURE RUNTIME WRAPPERS — compose pure helpers + HTTP + Eloquent. // Not unit-tested in this phase; exercised via smoke test once the @@ -410,4 +446,22 @@ public function createOrderFromServiceQuote(ServiceQuote $serviceQuote, Order $o return $row; } + + /** + * Fetch tracking status via GET /v1/tracking/{trackingNumber}. + */ + public function getTrackingStatus(string $trackingNumber): array + { + $response = $this->get('tracking/' . $trackingNumber) ?? []; + return static::normalizeTrackingResponse($response); + } + + /** + * Void a shipment via DELETE /v1/shipments/{shipmentId}. + */ + public function voidShipment(string $shipmentId): bool + { + $response = $this->delete('shipments/' . $shipmentId) ?? []; + return static::normalizeVoidResponse($response); + } } diff --git a/server/tests/Integrations/ParcelPath/ParcelPathTrackingNormalizerTest.php b/server/tests/Integrations/ParcelPath/ParcelPathTrackingNormalizerTest.php new file mode 100644 index 00000000..cf5cd4c4 --- /dev/null +++ b/server/tests/Integrations/ParcelPath/ParcelPathTrackingNormalizerTest.php @@ -0,0 +1,103 @@ + 'in_transit', + 'carrier' => 'ups', + 'events' => [ + ['code' => 'pickup', 'status' => 'Picked up', 'timestamp' => '2026-04-07T10:00:00Z', 'location' => 'SFO', 'details' => 'Origin scan'], + ], + ]); + expect($row['status'])->toBe('IN_TRANSIT'); + expect($row['carrier'])->toBe('UPS'); + expect($row['events'])->toHaveCount(1); + expect($row['events'][0]['code'])->toBe('PICKUP'); + expect($row['events'][0]['status'])->toBe('Picked up'); + expect($row['events'][0]['timestamp'])->toBe('2026-04-07T10:00:00Z'); + expect($row['events'][0]['location'])->toBe('SFO'); + expect($row['events'][0]['details'])->toBe('Origin scan'); +}); + +test('normalizeTrackingResponse uppercases status and carrier', function () { + $row = ParcelPath::normalizeTrackingResponse(['status' => 'delivered', 'carrier' => 'usps']); + expect($row['status'])->toBe('DELIVERED'); + expect($row['carrier'])->toBe('USPS'); +}); + +test('normalizeTrackingResponse uppercases event codes', function () { + $row = ParcelPath::normalizeTrackingResponse([ + 'events' => [['code' => 'delivered'], ['code' => 'out_for_delivery']], + ]); + expect($row['events'][0]['code'])->toBe('DELIVERED'); + expect($row['events'][1]['code'])->toBe('OUT_FOR_DELIVERY'); +}); + +test('normalizeTrackingResponse preserves event order', function () { + $row = ParcelPath::normalizeTrackingResponse([ + 'events' => [ + ['code' => 'a'], + ['code' => 'b'], + ['code' => 'c'], + ], + ]); + expect(array_column($row['events'], 'code'))->toBe(['A', 'B', 'C']); +}); + +test('normalizeTrackingResponse defaults status to UNKNOWN when missing', function () { + $row = ParcelPath::normalizeTrackingResponse(['carrier' => 'ups']); + expect($row['status'])->toBe('UNKNOWN'); +}); + +test('normalizeTrackingResponse defaults carrier to empty string when missing', function () { + $row = ParcelPath::normalizeTrackingResponse(['status' => 'delivered']); + expect($row['carrier'])->toBe(''); +}); + +test('normalizeTrackingResponse returns empty events array when key is missing', function () { + $row = ParcelPath::normalizeTrackingResponse([]); + expect($row['events'])->toBe([]); + expect($row['status'])->toBe('UNKNOWN'); + expect($row['carrier'])->toBe(''); +}); + +test('normalizeTrackingResponse handles events with null location and details', function () { + $row = ParcelPath::normalizeTrackingResponse([ + 'events' => [['code' => 'x', 'status' => 'ok']], + ]); + expect($row['events'][0]['location'])->toBeNull(); + expect($row['events'][0]['details'])->toBeNull(); + expect($row['events'][0]['timestamp'])->toBeNull(); +}); + +// ── normalizeVoidResponse ──────────────────────────────────────────────── + +test('normalizeVoidResponse returns true on {voided: true}', function () { + expect(ParcelPath::normalizeVoidResponse(['voided' => true]))->toBeTrue(); +}); + +test('normalizeVoidResponse returns true on {status: voided} case-insensitive', function () { + expect(ParcelPath::normalizeVoidResponse(['status' => 'voided']))->toBeTrue(); + expect(ParcelPath::normalizeVoidResponse(['status' => 'VOIDED']))->toBeTrue(); + expect(ParcelPath::normalizeVoidResponse(['status' => 'Voided']))->toBeTrue(); +}); + +test('normalizeVoidResponse returns true on {status: cancelled}', function () { + expect(ParcelPath::normalizeVoidResponse(['status' => 'cancelled']))->toBeTrue(); + expect(ParcelPath::normalizeVoidResponse(['status' => 'CANCELLED']))->toBeTrue(); +}); + +test('normalizeVoidResponse returns false on {voided: false}', function () { + expect(ParcelPath::normalizeVoidResponse(['voided' => false]))->toBeFalse(); +}); + +test('normalizeVoidResponse returns false on empty array', function () { + expect(ParcelPath::normalizeVoidResponse([]))->toBeFalse(); +}); + +test('normalizeVoidResponse returns false on {status: pending}', function () { + expect(ParcelPath::normalizeVoidResponse(['status' => 'pending']))->toBeFalse(); +}); From 6494c48a1b3cdd7b6dcccbaf30f4f00ac9c63528 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 00:19:33 -0700 Subject: [PATCH 10/25] feat(labels): serve carrier labels from File before internal fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends LabelController::getLabel to check for a File record in the carrier-labels folder on the resolved subject before rendering the internal Blade label. When a carrier label exists (ParcelPath / UPS / USPS label binaries stamped by the bridge layer), stream it from the Storage disk. Honors ?format=base64 by returning the encoded bytes in a JSON response; otherwise streams the raw file. Backward compatible: orders without carrier labels still get the internal Blade label as before. No Pest test — the controller edit is exercised via the smoke test path; the label-persisting code that creates the File record already has unit coverage via the ParcelPath label normalizer tests. --- .../Controllers/Api/v1/LabelController.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/src/Http/Controllers/Api/v1/LabelController.php b/server/src/Http/Controllers/Api/v1/LabelController.php index 5fba743e..00be99ca 100644 --- a/server/src/Http/Controllers/Api/v1/LabelController.php +++ b/server/src/Http/Controllers/Api/v1/LabelController.php @@ -6,7 +6,9 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\Http\Controllers\Controller; +use Fleetbase\Models\File; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; class LabelController extends Controller { @@ -39,6 +41,24 @@ public function getLabel(string $publicId, Request $request) return response()->apiError('Unable to render label.'); } + $carrierLabel = File::where('subject_uuid', $subject->uuid) + ->where('folder', 'carrier-labels') + ->latest() + ->first(); + + if ($carrierLabel) { + $disk = Storage::disk( + $carrierLabel->disk ?: config('filesystems.default') + ); + if ($format === 'base64') { + return response()->json([ + 'data' => base64_encode($disk->get($carrierLabel->path)), + ]); + } + + return $disk->response($carrierLabel->path); + } + switch ($format) { case 'pdf': case 'stream': From 75eb7972259cd9b7791fd005e90d5d37e0cf5fbd Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 07:07:44 -0700 Subject: [PATCH 11/25] feat(parcelpath): tracking poll job + terminal status helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PollParcelPathTrackingJob, a queueable scheduled every 15 minutes via the existing scheduleCommands closure in FleetOpsServiceProvider. Queries active orders with a parcelpath_shipment_id, asks the ParcelPath bridge for the latest tracking state, firstOrCreate()s one TrackingStatus row per new event, and transitions the Order status when the normalized tracking code is terminal. Pure: ParcelPath::terminalOrderStatus maps a normalized tracking code (DELIVERED / RETURN_TO_SENDER / RETURNED / ...) to the Fleetbase Order status to transition into (completed / returned / null). Case-insensitive. Per-order failures (HTTP, vendor misconfig, missing tracking number) are reported via report() but do not abort the batch — the next poll cycle will pick them up. Impure job.handle() is untested here; it composes the Eloquent layer and runs under the Laravel scheduler. Pure mapping has unit tests; the tracking normalizer the job depends on is already covered by the Task 5 tests. Tests (Pest): 9 new (terminalOrderStatus). Full suite: 85 passed (173 assertions). --- .../Integrations/ParcelPath/ParcelPath.php | 14 +++ server/src/Jobs/PollParcelPathTrackingJob.php | 102 ++++++++++++++++++ .../src/Providers/FleetOpsServiceProvider.php | 3 + .../ParcelPathTerminalStatusTest.php | 41 +++++++ 4 files changed, 160 insertions(+) create mode 100644 server/src/Jobs/PollParcelPathTrackingJob.php create mode 100644 server/tests/Integrations/ParcelPath/ParcelPathTerminalStatusTest.php diff --git a/server/src/Integrations/ParcelPath/ParcelPath.php b/server/src/Integrations/ParcelPath/ParcelPath.php index 7c4b4665..d4b1f25c 100644 --- a/server/src/Integrations/ParcelPath/ParcelPath.php +++ b/server/src/Integrations/ParcelPath/ParcelPath.php @@ -335,6 +335,20 @@ public static function normalizeTrackingResponse(array $response): array ]; } + /** + * Map a normalized tracking status code into the Fleetbase Order status + * to transition to when this is a terminal event, or null when the order + * should stay in its current state. Case-insensitive. + */ + public static function terminalOrderStatus(string $normalizedStatus): ?string + { + return match (strtoupper($normalizedStatus)) { + 'DELIVERED' => 'completed', + 'RETURN_TO_SENDER', 'RETURNED' => 'returned', + default => null, + }; + } + /** * Normalize the DELETE /v1/shipments/{id} response into a boolean. */ diff --git a/server/src/Jobs/PollParcelPathTrackingJob.php b/server/src/Jobs/PollParcelPathTrackingJob.php new file mode 100644 index 00000000..d92461f9 --- /dev/null +++ b/server/src/Jobs/PollParcelPathTrackingJob.php @@ -0,0 +1,102 @@ +whereIn('status', ['dispatched', 'in_transit', 'out_for_delivery']) + ->whereNotNull('meta->integrated_vendor_order->parcelpath_shipment_id') + ->get(); + + foreach ($orders as $order) { + try { + $this->pollOrder($order); + } catch (Throwable $e) { + report($e); + continue; + } + } + } + + protected function pollOrder(Order $order): void + { + $vendor = IntegratedVendor::find($order->facilitator_uuid); + if (!$vendor || $vendor->provider !== 'parcelpath') { + return; + } + + $bridge = $vendor->api(); + if (!$bridge instanceof ParcelPath) { + return; + } + + $trackingNumber = $order->getMeta('integrated_vendor_order.tracking_number'); + if (!$trackingNumber) { + return; + } + + $result = $bridge->getTrackingStatus($trackingNumber); + $trackingNumberModel = $order->trackingNumber; + if (!$trackingNumberModel) { + return; + } + + foreach ($result['events'] as $event) { + TrackingStatus::firstOrCreate( + [ + 'tracking_number_uuid' => $trackingNumberModel->uuid, + 'code' => $event['code'], + 'created_at' => $event['timestamp'] ?: now(), + ], + [ + 'company_uuid' => $order->company_uuid, + 'status' => $event['status'] ?: $event['code'], + 'details' => $event['location'] ?? $event['details'] ?? null, + ] + ); + } + + $terminal = ParcelPath::terminalOrderStatus($result['status'] ?? ''); + if ($terminal && $order->status !== $terminal) { + $order->status = $terminal; + $order->save(); + } + } +} diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index 452ab51e..53d26b9f 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -98,6 +98,9 @@ public function boot() $schedule->command('fleetops:dispatch-adhoc')->everyMinute()->withoutOverlapping()->storeOutputInDb(); $schedule->command('fleetops:update-estimations')->everyTenMinutes()->withoutOverlapping(); $schedule->command('fleetops:purge-service-quotes')->daily()->withoutOverlapping(); + $schedule->job(new \Fleetbase\FleetOps\Jobs\PollParcelPathTrackingJob()) + ->everyFifteenMinutes() + ->withoutOverlapping(); }); $this->registerNotifications(); $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); diff --git a/server/tests/Integrations/ParcelPath/ParcelPathTerminalStatusTest.php b/server/tests/Integrations/ParcelPath/ParcelPathTerminalStatusTest.php new file mode 100644 index 00000000..40a9e51d --- /dev/null +++ b/server/tests/Integrations/ParcelPath/ParcelPathTerminalStatusTest.php @@ -0,0 +1,41 @@ +toBe('completed'); +}); + +test('terminalOrderStatus is case-insensitive for delivered', function () { + expect(ParcelPath::terminalOrderStatus('delivered'))->toBe('completed'); +}); + +test('terminalOrderStatus RETURN_TO_SENDER returns returned', function () { + expect(ParcelPath::terminalOrderStatus('RETURN_TO_SENDER'))->toBe('returned'); +}); + +test('terminalOrderStatus RETURNED returns returned', function () { + expect(ParcelPath::terminalOrderStatus('RETURNED'))->toBe('returned'); +}); + +test('terminalOrderStatus IN_TRANSIT returns null', function () { + expect(ParcelPath::terminalOrderStatus('IN_TRANSIT'))->toBeNull(); +}); + +test('terminalOrderStatus OUT_FOR_DELIVERY returns null', function () { + expect(ParcelPath::terminalOrderStatus('OUT_FOR_DELIVERY'))->toBeNull(); +}); + +test('terminalOrderStatus EXCEPTION returns null', function () { + expect(ParcelPath::terminalOrderStatus('EXCEPTION'))->toBeNull(); +}); + +test('terminalOrderStatus empty string returns null', function () { + expect(ParcelPath::terminalOrderStatus(''))->toBeNull(); +}); + +test('terminalOrderStatus arbitrary string returns null', function () { + expect(ParcelPath::terminalOrderStatus('WAT_IS_THIS'))->toBeNull(); +}); From f28623daaf6f6508d7ec0da78507b7027bd695ab Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 09:23:07 -0700 Subject: [PATCH 12/25] feat(ui): carrier onboarding panel for ParcelPath / direct UPS/USPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds — a Glimmer component that nudges operators toward ParcelPath as the recommended Phase 1 entry point for small-parcel shipping while keeping the direct UPS and USPS bridges one click away. Layout: a featured ParcelPath card with the "Recommended" badge, plus a collapsible
block exposing direct UPS Direct and USPS Direct cards plus a hybrid-mode tip. Each Connect button calls integratedVendorActions.create({provider}) when that action exists, falling back to a router transition into the existing IntegratedVendor new-record flow with the provider as a query param. No new routes; this composes the form already rendered by addon/components/integrated-vendor/form.hbs from the registry. en-us translations added for the panel copy. Other locales fall through to the en-us defaults (matching the existing fleetops i18n pattern). The classic addon/app component pair is in place (addon/components/carrier-onboarding-panel.hbs+.js plus the app/components re-export shim) so consumers can mount the panel as from any host route. --- addon/components/carrier-onboarding-panel.hbs | 61 +++++++++++++++++++ addon/components/carrier-onboarding-panel.js | 37 +++++++++++ app/components/carrier-onboarding-panel.js | 1 + translations/en-us.yaml | 15 +++++ 4 files changed, 114 insertions(+) create mode 100644 addon/components/carrier-onboarding-panel.hbs create mode 100644 addon/components/carrier-onboarding-panel.js create mode 100644 app/components/carrier-onboarding-panel.js diff --git a/addon/components/carrier-onboarding-panel.hbs b/addon/components/carrier-onboarding-panel.hbs new file mode 100644 index 00000000..ec0ae8eb --- /dev/null +++ b/addon/components/carrier-onboarding-panel.hbs @@ -0,0 +1,61 @@ +
+
+ {{t "carrier-onboarding.heading"}} +
+

+ {{t "carrier-onboarding.description"}} +

+ + {{!-- Recommended: ParcelPath --}} +
+
+
+ ParcelPath +
+
+

{{t "carrier-onboarding.parcelpath.title"}}

+ {{t "carrier-onboarding.recommended"}} +
+

+ {{t "carrier-onboarding.parcelpath.description"}} +

+
+
+
+
+ + {{!-- Alternative: direct carrier accounts --}} +
+ + {{t "carrier-onboarding.direct.summary"}} + +

+ {{t "carrier-onboarding.direct.description"}} +

+
+
+
+ UPS + {{t "carrier-onboarding.direct.ups"}} +
+
+
+
+ USPS + {{t "carrier-onboarding.direct.usps"}} +
+
+
+

+ {{t "carrier-onboarding.direct.hybrid-note"}} +

+
+
diff --git a/addon/components/carrier-onboarding-panel.js b/addon/components/carrier-onboarding-panel.js new file mode 100644 index 00000000..66fa9180 --- /dev/null +++ b/addon/components/carrier-onboarding-panel.js @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +/** + * CarrierOnboardingPanel + * + * Onboarding surface that nudges new operators toward ParcelPath as the + * default ParcelPath/UPS/USPS integration path while still exposing the + * direct UPS and USPS bridges for shippers with their own carrier + * contracts. Uses the existing IntegratedVendor create flow — clicking + * a "Connect" button hands the chosen provider to + * `integratedVendorActions.create`, which routes to the same form the + * Settings → Integrated Vendors page already renders. + */ +export default class CarrierOnboardingPanelComponent extends Component { + @service integratedVendorActions; + @service router; + + @action + connectProvider(providerCode) { + // Prefer the existing IntegratedVendor create action if it exposes + // a `create` modal/form (mirrors how the settings page wires up + // new vendors). Fall back to a route transition with a query + // param so the IntegratedVendor form can preselect the provider. + if ( + this.integratedVendorActions && + typeof this.integratedVendorActions.create === 'function' + ) { + return this.integratedVendorActions.create({ provider: providerCode }); + } + return this.router.transitionTo( + 'console.fleet-ops.management.integrated-vendors.new', + { queryParams: { provider: providerCode } } + ); + } +} diff --git a/app/components/carrier-onboarding-panel.js b/app/components/carrier-onboarding-panel.js new file mode 100644 index 00000000..535fe96e --- /dev/null +++ b/app/components/carrier-onboarding-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/carrier-onboarding-panel'; diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 8affb124..a2820ba7 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -1716,3 +1716,18 @@ settings: button-continue: Continue button-start: Start onboard now button-in-progress: Onboard in progress +carrier-onboarding: + heading: Connect a Small Parcel Carrier + description: Choose how you want to ship UPS and USPS labels from FleetOps. Most operators start with ParcelPath; bring your own carrier accounts only if you already have negotiated UPS/USPS contracts. + recommended: Recommended + parcelpath: + title: ParcelPath (UPS + USPS) + description: Instant access to discounted UPS and USPS rates (60–89% off retail). One API key — ParcelPath holds the carrier relationships, handles labels, tracking, and returns. Zero configuration. + cta: Connect ParcelPath + direct: + summary: Use your own carrier accounts + description: Already have UPS or USPS negotiated rates? Add direct integrations using your own credentials. Each direct account ships under your own contract. + ups: UPS Direct + usps: USPS Direct + connect: Connect + hybrid-note: Tip — you can run ParcelPath and direct accounts side by side. The order rate selector will show all matching rates and you pick the best one per shipment. From ae04755819a391b8c06eb39e1a38bfb02b4b921c Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 09:28:37 -0700 Subject: [PATCH 13/25] feat(ui): rate comparison enriched for integrated vendor quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts the existing order form rate selector (addon/components/order/form/service-rate.hbs +.js) so quotes from an IntegratedVendor facilitator (ParcelPath / UPS Direct / USPS Direct) render with carrier-aware metadata instead of falling through to the generic public_id label. Component: - Adds isIntegratedVendorFacilitator getter (delegates to @resource.facilitator.isIntegratedVendor). - queryServiceRates now forks: when the facilitator is integrated vendor, skip the local ServiceRate query and call getServiceQuotes directly with a null serviceRate, which the existing service-rate- actions service already handles by passing facilitator=public_id to /v1/service-quotes. - Refresh button stays enabled in the integrated-vendor path even without a selected ServiceRate. Template: - When serviceQuote.meta.carrier is present, render the carrier logo (/images/integrated-vendors/{carrier}.png), the service token headline, the carrier name, the estimated days (when present), and a "via {facilitator}" source label. Falls back to the existing public_id rendering when meta.carrier is absent (preserves backward compatibility for non-parcel facilitators). - The right-aligned badge shows the carrier when present (otherwise request_id). - data-test- attributes added on the carrier logo, service token, ETA, and source label so future component tests can target them. Translations: - order.fields.estimated-days now takes a {days} count - order.fields.via-facilitator takes a {facilitator} name The existing service-rate fetch path is unchanged for non-integrated vendors. No backend changes — service-rate-actions already passed facilitator=public_id when present, but the template never used it. --- addon/components/order/form/service-rate.hbs | 31 +++++++++++++++++--- addon/components/order/form/service-rate.js | 20 +++++++++++++ translations/en-us.yaml | 2 ++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/addon/components/order/form/service-rate.hbs b/addon/components/order/form/service-rate.hbs index 39d8611e..8a07a558 100644 --- a/addon/components/order/form/service-rate.hbs +++ b/addon/components/order/form/service-rate.hbs @@ -35,7 +35,7 @@ @icon="refresh" @text={{t "order.fields.refresh-button"}} @isLoading={{this.getServiceQuotes.isRunning}} - @disabled={{not this.selectedRate}} + @disabled={{and (not this.selectedRate) (not this.isIntegratedVendorFacilitator)}} @onClick={{perform this.getServiceQuotes this.selectedRate}} /> @@ -48,7 +48,7 @@
{{#each this.serviceQuotes as |serviceQuote|}} -
+
- + {{#if serviceQuote.meta.carrier}} + {{serviceQuote.meta.carrier}} + + {{else}} + + {{/if}} - {{serviceQuote.request_id}} + {{or serviceQuote.meta.carrier serviceQuote.request_id}}
diff --git a/addon/components/order/form/service-rate.js b/addon/components/order/form/service-rate.js index 23c379af..45f71b1d 100644 --- a/addon/components/order/form/service-rate.js +++ b/addon/components/order/form/service-rate.js @@ -13,9 +13,29 @@ export default class OrderFormServiceRateComponent extends Component { return this.args.resource?.order_config && this.args.resource?.payloadCoordinates?.length >= 2; } + /** + * True when the order's facilitator is an IntegratedVendor (e.g. + * ParcelPath / UPS Direct / USPS Direct). For these, the bridge layer + * resolves rates server-side from the vendor's API instead of from + * locally configured ServiceRate records, so the rate-selector + * dropdown is hidden and quotes load directly when the toggle flips + * on. + */ + get isIntegratedVendorFacilitator() { + return this.args.resource?.facilitator?.get?.('isIntegratedVendor') ?? false; + } + @task *queryServiceRates(toggled) { this.args.resource.servicable = toggled; if (!toggled) return; + + // Integrated-vendor path: skip the local ServiceRate query and fetch + // quotes straight from the bound vendor (ParcelPath / UPS / USPS). + if (this.isIntegratedVendorFacilitator) { + yield this.getServiceQuotes.perform(null); + return; + } + this.serviceRates = yield this.serviceRateActions.queryServiceRatesForOrder.perform(this.args.resource); } diff --git a/translations/en-us.yaml b/translations/en-us.yaml index a2820ba7..a671c491 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -618,6 +618,8 @@ order: no-service-quotes: No service quotes. input-order-routes: Input order route to view service quotes. service-quote-info: Select a real time service quote to apply to this order. Once a quote is applied to the order, it will become a purchased rate. Transactions will be tracked within the Fleetbase ledger. + estimated-days: '{days, plural, one {1 day} other {{days} days}}' + via-facilitator: 'via {facilitator}' prompts: update-details-success: '{orderId} details has been updated.' route-error: Route optimization failed, check route entry and try again. From f0cce793b5bdb207834210062b9b97bb1c28b52b Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 09:49:49 -0700 Subject: [PATCH 14/25] chore: ignore Pest workaround `vendor` symlink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /vendor to .gitignore. This is a local symlink (vendor -> server_vendor) created on each one-off Pest docker run to work around Pest's bin script hardcoding the literal `vendor/` path while fleetops uses a non-standard composer vendor-dir of `server_vendor`. Not real source — keeps `git status` clean and prevents accidental staging. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index c840f412..a047d631 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,11 @@ composer.lock /server_vendor /server/vendor/ +# Local Pest workaround: a `vendor -> server_vendor` symlink at the +# project root, created on each one-off pest run because Pest's bin +# script hardcodes the literal `vendor/` path while fleetops uses a +# non-standard composer vendor-dir of `server_vendor`. Not real source. +/vendor .phpunit.result.cache .php_cs.cache .php-cs-fixer.cache From 9602e70948717d023f79dc255fa2292939c40377 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 13:12:59 -0700 Subject: [PATCH 15/25] feat(ups): add UPSServiceType enum Mirrors LalamoveServiceType / ParcelPathServiceType shape (Collection of typed instances with dynamic property hydration, __get/__call, static all()/find()) for the 8 direct-carrier UPS service levels used by the Phase 2 UPS bridge: Ground (03), Ground Saver (93), 3 Day Select (12), 2nd Day Air (02), 2nd Day Air A.M. (59), Next Day Air (01), Next Day Air Early (14), Next Day Air Saver (13). Each entry carries a numeric service_code matching UPS's Service.Code identifier used in Rate Shop / Ship API requests. carrier='UPS' is set on every row so the static helpers don't need to guess provider. #[\AllowDynamicProperties] on the class silences the PHP 8.2 dynamic property deprecation while keeping the Lalamove-compatible hydration pattern. Tests (Pest): 9 passed, 60 assertions. --- .../src/Integrations/UPS/UPSServiceType.php | 77 +++++++++++++++++++ .../Integrations/UPS/UPSServiceTypeTest.php | 67 ++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 server/src/Integrations/UPS/UPSServiceType.php create mode 100644 server/tests/Integrations/UPS/UPSServiceTypeTest.php diff --git a/server/src/Integrations/UPS/UPSServiceType.php b/server/src/Integrations/UPS/UPSServiceType.php new file mode 100644 index 00000000..5d1639c4 --- /dev/null +++ b/server/src/Integrations/UPS/UPSServiceType.php @@ -0,0 +1,77 @@ + 'GROUND', 'description' => 'UPS Ground', 'service_code' => '03', 'carrier' => 'UPS'], + ['key' => 'GROUND_SAVER', 'description' => 'UPS Ground Saver', 'service_code' => '93', 'carrier' => 'UPS'], + ['key' => '3DS', 'description' => 'UPS 3 Day Select', 'service_code' => '12', 'carrier' => 'UPS'], + ['key' => '2DA', 'description' => 'UPS 2nd Day Air', 'service_code' => '02', 'carrier' => 'UPS'], + ['key' => '2DAM', 'description' => 'UPS 2nd Day Air A.M.', 'service_code' => '59', 'carrier' => 'UPS'], + ['key' => '1DA', 'description' => 'UPS Next Day Air', 'service_code' => '01', 'carrier' => 'UPS'], + ['key' => '1DAM', 'description' => 'UPS Next Day Air Early', 'service_code' => '14', 'carrier' => 'UPS'], + ['key' => '1DASAVER', 'description' => 'UPS Next Day Air Saver', 'service_code' => '13', 'carrier' => 'UPS'], + ]; + + public function __construct(array $details) + { + foreach ($details as $key => $value) { + $this->{$key} = $value; + } + } + + public function __get(string $key) + { + if (isset($this->{$key})) { + return $this->{$key}; + } + + return null; + } + + public function __call(string $key, $arguments) + { + if ($key === 'all') { + return collect(static::$serviceTypes)->mapInto(UPSServiceType::class); + } + + if (method_exists($this, $key)) { + $this->{$key}(...$arguments); + } + + return null; + } + + public function getKey() + { + return $this->key; + } + + public static function all() + { + return collect(static::$serviceTypes)->mapInto(UPSServiceType::class); + } + + public static function find($key) + { + if (is_callable($key)) { + return static::all()->first($key); + } + + if (is_string($key)) { + return static::all()->first(function ($detail) use ($key) { + return isset($detail->key) && strcasecmp($detail->key, $key) === 0; + }); + } + + return null; + } +} diff --git a/server/tests/Integrations/UPS/UPSServiceTypeTest.php b/server/tests/Integrations/UPS/UPSServiceTypeTest.php new file mode 100644 index 00000000..7ce5a5fa --- /dev/null +++ b/server/tests/Integrations/UPS/UPSServiceTypeTest.php @@ -0,0 +1,67 @@ +toHaveCount(8); +}); + +test('every UPS service has the correct carrier code', function () { + $expected = [ + 'GROUND' => '03', + 'GROUND_SAVER' => '93', + '2DA' => '02', + '2DAM' => '59', + '1DA' => '01', + '1DAM' => '14', + '1DASAVER' => '13', + '3DS' => '12', + ]; + + foreach ($expected as $key => $code) { + $type = UPSServiceType::find($key); + expect($type)->not->toBeNull("service $key should exist"); + expect($type->service_code)->toBe($code, "service $key should map to code $code"); + } +}); + +test('find() returns Ground with full metadata', function () { + $type = UPSServiceType::find('GROUND'); + expect($type)->not->toBeNull(); + expect($type->service_code)->toBe('03'); + expect($type->description)->toBe('UPS Ground'); +}); + +test('find() returns Ground Saver with code 93', function () { + $type = UPSServiceType::find('GROUND_SAVER'); + expect($type->service_code)->toBe('93'); + expect($type->description)->toBe('UPS Ground Saver'); +}); + +test('find() returns Next Day Air Early with code 14', function () { + expect(UPSServiceType::find('1DAM')->service_code)->toBe('14'); +}); + +test('find() is case-insensitive', function () { + expect(UPSServiceType::find('ground'))->not->toBeNull(); + expect(UPSServiceType::find('Ground')->service_code)->toBe('03'); +}); + +test('find() returns null for unknown key', function () { + expect(UPSServiceType::find('EXPRESS'))->toBeNull(); +}); + +test('find() accepts a callable', function () { + $type = UPSServiceType::find(fn ($t) => $t->service_code === '01'); + expect($type)->not->toBeNull(); + expect($type->key)->toBe('1DA'); +}); + +test('all() entries have key, description, service_code, carrier keys', function () { + foreach (UPSServiceType::all() as $type) { + expect($type->key)->not->toBeNull(); + expect($type->description)->not->toBeNull(); + expect($type->service_code)->not->toBeNull(); + expect($type->carrier)->toBe('UPS'); + } +}); From 3ba788c155b6164bbc1def2fd4136840e23dea2c Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 13:18:53 -0700 Subject: [PATCH 16/25] feat(ups): add UPSOAuthClient with injectable cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manages UPS's OAuth 2.0 client-credentials flow against the /security/v1/oauth/token endpoint. Tokens are cached per-clientId under the key ups_oauth_token_{clientId} with a TTL of (expires_in - 60) seconds, clamped to a 60-second minimum, so refresh happens well before carrier-side expiry and multi-tenant brokers cannot collide on each other's tokens. Cache strategy: - The class accepts an injectable \ArrayAccess cache in the constructor. In tests a plain \ArrayObject is passed so the unit tests run under Pest without booting Laravel. - Production wires through UPSOAuthClient::productionCache(), a tiny anonymous class that adapts Laravel's Cache facade to \ArrayAccess. The facade is only referenced inside that adapter method body — never at class-load time — so the Pest-without-Laravel-bootstrap property is preserved. - Deliberate choice: we stay at \ArrayAccess instead of a full PSR-16 CacheInterface because PSR-16 would require a bound cache implementation at import time, which breaks isolation. Sandbox vs production routes to wwwcie.ups.com vs onlinetools.ups.com. HTTP Basic auth. Non-2xx or missing access_token throws RuntimeException (caller handles retry). Port note: adapted from ParcelPath v9 UPSDAPService::getOAuthToken with the email-based production-URL override stripped per the Phase 2 porting rules. Only the generic sandbox/production host selection is preserved — no user-specific branch paths, no session-scoped routing. Tests (Pest): 12 passed, 20 assertions. Full suite: 106 passed. --- .../src/Integrations/UPS/UPSOAuthClient.php | 161 +++++++++++++++++ .../Integrations/UPS/UPSOAuthClientTest.php | 169 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 server/src/Integrations/UPS/UPSOAuthClient.php create mode 100644 server/tests/Integrations/UPS/UPSOAuthClientTest.php diff --git a/server/src/Integrations/UPS/UPSOAuthClient.php b/server/src/Integrations/UPS/UPSOAuthClient.php new file mode 100644 index 00000000..1f41fb0b --- /dev/null +++ b/server/src/Integrations/UPS/UPSOAuthClient.php @@ -0,0 +1,161 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->sandbox = $sandbox; + + $config = []; + if ($handler !== null) { + $config['handler'] = $handler; + } + $this->http = new Client($config); + + $this->cache = $cache ?? new \ArrayObject(); + } + + /** + * Factory for runtime use — wraps Laravel's Cache facade so the + * class still works without any arg plumbing from the IoC layer. + * Tests should NOT call this; they construct UPSOAuthClient + * directly with an \ArrayObject. + */ + public static function productionCache(): \ArrayAccess + { + return new class() implements \ArrayAccess { + public function offsetExists($offset): bool + { + return \Illuminate\Support\Facades\Cache::has($offset); + } + public function offsetGet($offset): mixed + { + return \Illuminate\Support\Facades\Cache::get($offset); + } + public function offsetSet($offset, $value): void + { + // TTL is set by putWithTtl() below; direct assignment falls + // back to the UPS-specific default of one hour minus safety. + \Illuminate\Support\Facades\Cache::put($offset, $value, 3600 - UPSOAuthClient::TTL_SAFETY); + } + public function offsetUnset($offset): void + { + \Illuminate\Support\Facades\Cache::forget($offset); + } + }; + } + + public function getAccessToken(): string + { + $key = $this->cacheKey(); + + if (isset($this->cache[$key]) && $this->cache[$key] !== null && $this->cache[$key] !== '') { + return (string) $this->cache[$key]; + } + + $response = $this->http->request('POST', $this->host() . self::TOKEN_PATH, [ + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret), + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ], + 'body' => 'grant_type=client_credentials', + 'http_errors' => false, + ]); + + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + throw new RuntimeException(sprintf( + 'UPS OAuth token request failed with HTTP %d', + $response->getStatusCode() + )); + } + + $body = json_decode((string) $response->getBody(), true) ?? []; + if (!isset($body['access_token']) || !is_string($body['access_token']) || $body['access_token'] === '') { + throw new RuntimeException('UPS OAuth response missing access_token'); + } + + $token = $body['access_token']; + $expiresIn = (int) ($body['expires_in'] ?? 3600); + $this->lastCachedTtl = max(self::TTL_MIN, $expiresIn - self::TTL_SAFETY); + + $this->cache[$key] = $token; + + return $token; + } + + /** + * TTL (in seconds) applied to the most recent successful + * token fetch. Exposed for observability and unit testing. + */ + public function getLastCachedTtl(): int + { + return $this->lastCachedTtl; + } + + private function host(): string + { + return $this->sandbox ? self::SANDBOX_HOST : self::PROD_HOST; + } + + private function cacheKey(): string + { + return 'ups_oauth_token_' . $this->clientId; + } +} diff --git a/server/tests/Integrations/UPS/UPSOAuthClientTest.php b/server/tests/Integrations/UPS/UPSOAuthClientTest.php new file mode 100644 index 00000000..8e4c7dc8 --- /dev/null +++ b/server/tests/Integrations/UPS/UPSOAuthClientTest.php @@ -0,0 +1,169 @@ + new Response( + $payload['status'] ?? 200, + ['Content-Type' => 'application/json'], + json_encode($payload['body'] ?? []) + ), + $responses + )); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($this->history)); + + $this->cache = new \ArrayObject(); + $this->client = new UPSOAuthClient($clientId, $clientSecret, $sandbox, $stack, $this->cache); + } +} + +// ── Host selection ─────────────────────────────────────────────────────── + +test('sandbox flag routes to wwwcie.ups.com', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 200, 'body' => ['access_token' => 't', 'expires_in' => 3600]], + ]); + $h->client->getAccessToken(); + + expect((string) $h->history[0]['request']->getUri()) + ->toStartWith('https://wwwcie.ups.com/security/v1/oauth/token'); +}); + +test('production flag routes to onlinetools.ups.com', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', false, [ + ['status' => 200, 'body' => ['access_token' => 't', 'expires_in' => 3600]], + ]); + $h->client->getAccessToken(); + + expect((string) $h->history[0]['request']->getUri()) + ->toStartWith('https://onlinetools.ups.com/security/v1/oauth/token'); +}); + +// ── Token fetch ────────────────────────────────────────────────────────── + +test('first call fetches token from UPS and stores it', function () { + $h = new UPSOAuthTestHarness('my-client', 'my-secret', true, [ + ['status' => 200, 'body' => ['access_token' => 'abc-token', 'expires_in' => 3600]], + ]); + + $token = $h->client->getAccessToken(); + + expect($token)->toBe('abc-token'); + expect($h->cache['ups_oauth_token_my-client'])->toBe('abc-token'); +}); + +test('request uses HTTP Basic auth with client_id and client_secret', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 200, 'body' => ['access_token' => 't', 'expires_in' => 3600]], + ]); + $h->client->getAccessToken(); + + $auth = $h->history[0]['request']->getHeaderLine('Authorization'); + expect($auth)->toBe('Basic ' . base64_encode('cid:csec')); +}); + +test('request body is grant_type=client_credentials form-encoded', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 200, 'body' => ['access_token' => 't', 'expires_in' => 3600]], + ]); + $h->client->getAccessToken(); + + $req = $h->history[0]['request']; + expect($req->getMethod())->toBe('POST'); + expect($req->getHeaderLine('Content-Type'))->toContain('application/x-www-form-urlencoded'); + expect((string) $req->getBody())->toBe('grant_type=client_credentials'); +}); + +// ── Cache short-circuit ────────────────────────────────────────────────── + +test('second call within TTL returns cached token without HTTP', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 200, 'body' => ['access_token' => 'first-token', 'expires_in' => 3600]], + ]); + + $first = $h->client->getAccessToken(); + $second = $h->client->getAccessToken(); + + expect($first)->toBe('first-token'); + expect($second)->toBe('first-token'); + expect($h->history)->toHaveCount(1); +}); + +test('pre-populated cache entry short-circuits the HTTP call entirely', function () { + $h = new UPSOAuthTestHarness('pre-cid', 'csec', true, []); + $h->cache['ups_oauth_token_pre-cid'] = 'precached-token'; + + $token = $h->client->getAccessToken(); + + expect($token)->toBe('precached-token'); + expect($h->history)->toHaveCount(0); +}); + +// ── TTL safety margin ──────────────────────────────────────────────────── + +test('getLastTtl returns expires_in minus 60 seconds', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 200, 'body' => ['access_token' => 't', 'expires_in' => 3600]], + ]); + $h->client->getAccessToken(); + + expect($h->client->getLastCachedTtl())->toBe(3540); +}); + +test('getLastTtl clamps to minimum 60 seconds when expires_in is very small', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 200, 'body' => ['access_token' => 't', 'expires_in' => 30]], + ]); + $h->client->getAccessToken(); + + expect($h->client->getLastCachedTtl())->toBeGreaterThanOrEqual(60); +}); + +// ── Cache key scoping ──────────────────────────────────────────────────── + +test('cache key is namespaced by clientId so multi-tenant accounts do not collide', function () { + $a = new UPSOAuthTestHarness('tenant-a', 's', true, [ + ['status' => 200, 'body' => ['access_token' => 'token-a', 'expires_in' => 3600]], + ]); + $b = new UPSOAuthTestHarness('tenant-b', 's', true, [ + ['status' => 200, 'body' => ['access_token' => 'token-b', 'expires_in' => 3600]], + ]); + + $a->client->getAccessToken(); + $b->client->getAccessToken(); + + expect($a->cache['ups_oauth_token_tenant-a'])->toBe('token-a'); + expect($b->cache['ups_oauth_token_tenant-b'])->toBe('token-b'); +}); + +// ── Error handling ─────────────────────────────────────────────────────── + +test('non-2xx response throws a RuntimeException', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 401, 'body' => ['error' => 'invalid_client']], + ]); + + expect(fn () => $h->client->getAccessToken())->toThrow(RuntimeException::class); +}); + +test('missing access_token in response throws a RuntimeException', function () { + $h = new UPSOAuthTestHarness('cid', 'csec', true, [ + ['status' => 200, 'body' => ['expires_in' => 3600]], + ]); + + expect(fn () => $h->client->getAccessToken())->toThrow(RuntimeException::class); +}); From e40354a04e9c42ad449dd2677c878bd878480fe7 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 13:58:00 -0700 Subject: [PATCH 17/25] feat(broker): shipper_client_uuid on integrated_vendors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds shipper_client_uuid to integrated_vendors so a broker can scope a single carrier credential record to a specific shipper client (modeled as a fleetops Vendor). When NULL, the record acts as the broker-level catch-all for the given provider — matching the resolver fallback behavior that ServiceQuoteController auto-resolve will ship in Task 19. Schema: - uuid column, nullable, after company_uuid - FK to vendors.uuid ON DELETE SET NULL - composite index (company_uuid, provider, shipper_client_uuid) named iv_company_provider_shipper_idx for the two-stage lookup in the resolver: first by (provider, shipper_client_uuid=X), then fallback to (provider, shipper_client_uuid IS NULL) IntegratedVendor model: - shipper_client_uuid added to $fillable - new shipperClient() belongsTo relationship targeting Fleetbase\FleetOps\Models\Vendor on uuid Tests (Pest): 4 reflection-based assertions (4 functional, 1 deprecation noise from IntegratedVendor.php:14 which carries a pre-existing PHP 8.2 optional-parameter warning outside this PR's scope — not introduced here, not modified here). Full suite: 109 passed. Migration executed live via php artisan migrate --path=/fleetbase/api/vendor/fleetbase/fleetops/server/migrations --realpath --force Schema verified post-migration: column present, index spans 3 columns, FK present. --- ...lient_uuid_to_integrated_vendors_table.php | 44 +++++++++++++++++++ server/src/Models/IntegratedVendor.php | 15 +++++++ .../IntegratedVendorShipperClientTest.php | 35 +++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 server/migrations/2026_04_09_000001_add_shipper_client_uuid_to_integrated_vendors_table.php create mode 100644 server/tests/Support/IntegratedVendorShipperClientTest.php diff --git a/server/migrations/2026_04_09_000001_add_shipper_client_uuid_to_integrated_vendors_table.php b/server/migrations/2026_04_09_000001_add_shipper_client_uuid_to_integrated_vendors_table.php new file mode 100644 index 00000000..41f88121 --- /dev/null +++ b/server/migrations/2026_04_09_000001_add_shipper_client_uuid_to_integrated_vendors_table.php @@ -0,0 +1,44 @@ +uuid('shipper_client_uuid')->nullable()->after('company_uuid'); + $table->foreign('shipper_client_uuid') + ->references('uuid')->on('vendors') + ->onDelete('set null'); + $table->index(['company_uuid', 'provider', 'shipper_client_uuid'], 'iv_company_provider_shipper_idx'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('integrated_vendors', function (Blueprint $table) { + $table->dropIndex('iv_company_provider_shipper_idx'); + $table->dropForeign(['shipper_client_uuid']); + $table->dropColumn('shipper_client_uuid'); + }); + } +}; diff --git a/server/src/Models/IntegratedVendor.php b/server/src/Models/IntegratedVendor.php index 20b80b92..dc08c68a 100644 --- a/server/src/Models/IntegratedVendor.php +++ b/server/src/Models/IntegratedVendor.php @@ -54,6 +54,7 @@ class IntegratedVendor extends Model '_key', 'public_id', 'company_uuid', + 'shipper_client_uuid', 'created_by_uuid', 'host', 'namespace', @@ -150,6 +151,20 @@ public function company() return $this->belongsTo(\Fleetbase\Models\Company::class); } + /** + * The shipper client (Vendor) this IntegratedVendor credential record is + * scoped to, if any. When null, this record is the broker-level catch-all + * for the given provider. Used by ServiceQuoteController auto-resolve to + * route orders through the right carrier credential based on the order's + * customer Vendor. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function shipperClient() + { + return $this->belongsTo(\Fleetbase\FleetOps\Models\Vendor::class, 'shipper_client_uuid', 'uuid'); + } + public function setWebhookUrlAttribute($webhookUrl = null) { if (empty($webhookUrl)) { diff --git a/server/tests/Support/IntegratedVendorShipperClientTest.php b/server/tests/Support/IntegratedVendorShipperClientTest.php new file mode 100644 index 00000000..c6390c51 --- /dev/null +++ b/server/tests/Support/IntegratedVendorShipperClientTest.php @@ -0,0 +1,35 @@ +getDefaultProperties(); + return $defaults['fillable'] ?? []; +} + +test('IntegratedVendor fillable includes shipper_client_uuid', function () { + expect(ivFillable())->toContain('shipper_client_uuid'); +}); + +test('IntegratedVendor fillable still includes company_uuid (no regression)', function () { + expect(ivFillable())->toContain('company_uuid'); +}); + +test('IntegratedVendor has shipperClient relationship method', function () { + expect(method_exists(IntegratedVendor::class, 'shipperClient'))->toBeTrue(); +}); + +test('shipperClient method signature exists via reflection', function () { + $ref = new ReflectionClass(IntegratedVendor::class); + $method = $ref->getMethod('shipperClient'); + expect($method->isPublic())->toBeTrue(); + expect($method->isStatic())->toBeFalse(); +}); From 1f804f7450c30e701567d5009becf872b60661bc Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 14:34:01 -0700 Subject: [PATCH 18/25] feat(ups): Rate Shop rating bridge with pure/impure split Introduces the UPS bridge class following the Phase 1 ParcelPath pattern: private host/sandbox fields, Guzzle Client with injectable HandlerStack, chainable setRequestId/setOptions/setIntegratedVendor, pure static helpers, thin impure instance wrapper. Pure (static, unit-testable without Laravel bootstrap): - dimensionalWeight(L,W,H,divisor=139): float - billableWeight(actualLb,dimLb): float [max() of the two] - placeToUpsAddress(place): array [UPS Address shape] - entityToUpsPackage(entity): array [PackagingType/Dimensions/ PackageWeight with dim weight baked in] - buildRateShopRequest(shipFrom, shipTo, packages, accountNumber, ?serviceCode): array [forks Shop vs Rate by presence of serviceCode] - normalizeRateShopResponse(array, markupType, markupValue): array - prefers NegotiatedRateCharges.TotalCharge over TotalCharges (AGP selection) when present - handles single RatedShipment returned as object OR array - cents conversion with sub-cent rounding - flat / percent markup on top of carrier amount - resolves service description via UPSServiceType::find - returns rows ready for ServiceQuote::create Impure wrapper: - getQuoteFromPayload(Payload, ?serviceType, ...) composes pure helpers + UPSOAuthClient + Guzzle POST to /api/rating/v2403/rate/{Shop|Rate}, writes ServiceQuote + ServiceQuoteItem rows under the bound IntegratedVendor's company_uuid with the vendor's markup options applied. Constructor accepts an injectable Guzzle HandlerStack and an injectable UPSOAuthClient so Pest tests can stub both without touching real UPS endpoints. ## Extraction rule compliance Per the Phase 2 porting rules, NO user-specific or environment-specific logic from ParcelPath v9 is carried over. Only generic UPS Rating API semantics are implemented. Specifically NOT carried over (deferred to future PRs if needed): - NBNL (Non-Barcoded Non-Letter) barcode-to-PDF handling - Multi-package letter merge (mergeUPSRateResponse) - Ground Saver service 93 estimated-days derivation - Any email-based UPS URL override or session-scoped routing PP v9 source was not available in this workspace during this port; the bridge was built directly against UPS's public Rating API v2403 documentation, which aligns with the extraction rule. Tests (Pest): 22 passed covering dim weight math, billable weight selection, place/entity mapping, Shop-vs-Rate request assembly, multi-package support, response normalization (AGP preference, object/array handling, markup arithmetic, service resolution, sub-cent rounding, empty-response defense). Full suite: 131 passed. --- server/src/Integrations/UPS/UPS.php | 459 ++++++++++++++++++ .../Integrations/UPS/UPSRatesBuilderTest.php | 263 ++++++++++ 2 files changed, 722 insertions(+) create mode 100644 server/src/Integrations/UPS/UPS.php create mode 100644 server/tests/Integrations/UPS/UPSRatesBuilderTest.php diff --git a/server/src/Integrations/UPS/UPS.php b/server/src/Integrations/UPS/UPS.php new file mode 100644 index 00000000..43435cde --- /dev/null +++ b/server/src/Integrations/UPS/UPS.php @@ -0,0 +1,459 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->accountNumber = $accountNumber; + $this->isSandbox = $sandbox; + + $clientConfig = [ + 'base_uri' => $this->baseUrl(), + ]; + + if ($handler !== null) { + $clientConfig['handler'] = $handler; + } + + $this->client = new Client($clientConfig); + + $this->oauthClient = $oauthClient ?? ( + $clientId !== null && $clientSecret !== null + ? new UPSOAuthClient($clientId, $clientSecret, $sandbox, $handler) + : null + ); + } + + public function setRequestId(?string $requestId): self + { + $this->requestId = $requestId; + return $this; + } + + public function setOptions(?array $options = []): self + { + $this->options = array_merge($this->options, (array) $options); + return $this; + } + + public function getOptions(): array + { + return $this->options; + } + + public function setIntegratedVendor(IntegratedVendor $integratedVendor): self + { + $this->integratedVendor = $integratedVendor; + return $this; + } + + public function isSandbox(): bool + { + return $this->isSandbox; + } + + public function getAccountNumber(): ?string + { + return $this->accountNumber; + } + + public function baseUrl(): string + { + return $this->isSandbox ? $this->sandboxHost : $this->host; + } + + /** + * Execute an authenticated request against the UPS API. + */ + private function request(string $method, string $path, array $options = []) + { + if ($this->oauthClient === null) { + throw new \RuntimeException('UPS bridge is not configured with credentials or an OAuth client.'); + } + + $token = $this->oauthClient->getAccessToken(); + + $options['headers'] = array_merge($options['headers'] ?? [], [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'transactionSrc' => 'fleetops', + ]); + + if ($this->requestId !== null) { + $options['headers']['transId'] = $this->requestId; + } + + $options['http_errors'] = false; + + $response = $this->client->request($method, $path, $options); + return json_decode((string) $response->getBody(), true); + } + + public function post(string $path, array $options = []) + { + return $this->request('POST', $path, $options); + } + + public function delete(string $path, array $options = []) + { + return $this->request('DELETE', $path, $options); + } + + // ───────────────────────────────────────────────────────────────────── + // PURE HELPERS — request builders and response normalizers. + // Static, take plain arrays / duck-typed objects, unit-testable + // under Pest without booting Laravel. + // ───────────────────────────────────────────────────────────────────── + + /** + * Dimensional weight in pounds. Default divisor 139 is domestic US UPS. + * International UPS and USPS use 166. + */ + public static function dimensionalWeight(float $length, float $width, float $height, int $divisor = 139): float + { + if ($divisor <= 0) { + throw new \InvalidArgumentException('divisor must be positive'); + } + return ($length * $width * $height) / $divisor; + } + + /** + * Billable weight is the maximum of actual weight and dimensional weight. + */ + public static function billableWeight(float $actualLb, float $dimLb): float + { + return max($actualLb, $dimLb); + } + + /** + * Convert a Place-like object to the UPS Address shape. + * Defaults CountryCode to US when absent. + */ + public static function placeToUpsAddress(object $place): array + { + return [ + 'AddressLine' => [(string) ($place->street1 ?? '')], + 'City' => (string) ($place->city ?? ''), + 'StateProvinceCode' => (string) ($place->province ?? ''), + 'PostalCode' => (string) ($place->postal_code ?? ''), + 'CountryCode' => (string) ($place->country ?? 'US'), + ]; + } + + /** + * Convert an Entity-like parcel object to the UPS Package shape. + * Dimensions are reported in inches, weight in pounds, and billable + * weight is computed as max(actual, dimensional). + */ + public static function entityToUpsPackage(object $entity): array + { + $length = (float) ($entity->length ?? 0); + $width = (float) ($entity->width ?? 0); + $height = (float) ($entity->height ?? 0); + $actual = (float) ($entity->weight ?? 0); + + $dim = self::dimensionalWeight($length, $width, $height); + $billable = self::billableWeight($actual, $dim); + + return [ + 'PackagingType' => [ + 'Code' => '02', // 02 = Customer Supplied Package + 'Description' => 'Package', + ], + 'Dimensions' => [ + 'UnitOfMeasurement' => ['Code' => 'IN'], + 'Length' => (string) $length, + 'Width' => (string) $width, + 'Height' => (string) $height, + ], + 'PackageWeight' => [ + 'UnitOfMeasurement' => ['Code' => 'LBS'], + 'Weight' => sprintf('%.2f', $billable), + ], + ]; + } + + /** + * Build the POST /api/rating/v2403/rate/{shop|rate} request body. + * When $serviceCode is null, builds a Shop request that returns all + * service levels. When set, builds a Rate request for that specific + * UPS service. + */ + public static function buildRateShopRequest( + array $shipFrom, + array $shipTo, + array $packages, + string $accountNumber, + ?string $serviceCode = null + ): array { + $shipment = [ + 'Shipper' => [ + 'ShipperNumber' => $accountNumber, + 'Address' => $shipFrom, + ], + 'ShipTo' => ['Address' => $shipTo], + 'ShipFrom' => ['Address' => $shipFrom], + 'Package' => $packages, + ]; + + if ($serviceCode !== null && $serviceCode !== '') { + $shipment['Service'] = ['Code' => $serviceCode]; + } + + return [ + 'RateRequest' => [ + 'Request' => [ + 'RequestOption' => $serviceCode ? 'Rate' : 'Shop', + ], + 'Shipment' => $shipment, + ], + ]; + } + + /** + * Normalize the POST /api/rating/v2403/rate/{shop|rate} response into + * an array of rows ready for ServiceQuote::create(...). Amounts are + * converted to integer cents. NegotiatedRateCharges take precedence + * over TotalCharges when present (AGP selection). Flat or percent + * markup is applied in cents on top of the carrier amount. + * + * NOTE: a single RatedShipment may come back as an object OR as an + * array depending on UPS's response serialization — both are handled. + */ + public static function normalizeRateShopResponse( + array $response, + string $markupType = 'flat', + int $markupValue = 0 + ): array { + $rated = $response['RateResponse']['RatedShipment'] ?? null; + if ($rated === null) { + return []; + } + // UPS returns a single rated shipment as a direct object, not an array. + if (isset($rated['Service']) || isset($rated['TotalCharges'])) { + $rated = [$rated]; + } + + $rows = []; + foreach ($rated as $rs) { + $currency = (string) ( + $rs['TotalCharges']['CurrencyCode'] + ?? $rs['NegotiatedRateCharges']['TotalCharge']['CurrencyCode'] + ?? 'USD' + ); + + // Prefer negotiated rate when present. + $rawAmount = isset($rs['NegotiatedRateCharges']['TotalCharge']['MonetaryValue']) + ? $rs['NegotiatedRateCharges']['TotalCharge']['MonetaryValue'] + : ($rs['TotalCharges']['MonetaryValue'] ?? null); + + if ($rawAmount === null) { + continue; + } + + $carrierAmount = (int) round(((float) $rawAmount) * 100); + + $markup = $markupType === 'percent' + ? (int) round($carrierAmount * $markupValue / 100) + : $markupValue; + $sellAmount = $carrierAmount + $markup; + + $serviceCode = (string) ($rs['Service']['Code'] ?? ''); + $serviceType = UPSServiceType::find(function ($t) use ($serviceCode) { + return $t->service_code === $serviceCode; + }); + $description = $serviceType !== null + ? (string) $serviceType->description + : 'UPS Service ' . $serviceCode; + + $rows[] = [ + 'amount' => $sellAmount, + 'currency' => $currency, + 'service' => $description, + 'meta' => [ + 'carrier' => 'UPS', + 'service_code' => $serviceCode, + 'carrier_amount' => $carrierAmount, + 'markup_amount' => $markup, + 'markup_type' => $markupType, + ], + ]; + } + + return $rows; + } + + // ───────────────────────────────────────────────────────────────────── + // IMPURE RUNTIME WRAPPERS — compose pure helpers + HTTP + Eloquent. + // Not unit-tested in this phase; exercised via smoke test once the + // registry entry lands (Task 17) and a real UPS sandbox account is + // configured. + // ───────────────────────────────────────────────────────────────────── + + /** + * Rate a Payload via POST /api/rating/v2403/rate/{shop|rate} and + * persist each returned rate as a ServiceQuote + ServiceQuoteItem. + * Returns the created ServiceQuote array. Called from + * ServiceQuoteController::query through the IntegratedVendor bridge + * machinery. + */ + public function getQuoteFromPayload( + Payload $payload, + ?string $serviceType = null, + ?string $scheduledAt = null, + ?bool $isRouteOptimized = null + ): array { + $serviceCode = null; + if ($serviceType !== null && $serviceType !== '') { + $resolved = UPSServiceType::find($serviceType); + $serviceCode = $resolved?->service_code; + } + + $shipFrom = static::placeToUpsAddress($payload->pickup); + $shipTo = static::placeToUpsAddress($payload->dropoff); + $packages = []; + foreach ($payload->entities ?? [] as $entity) { + if (($entity->type ?? 'parcel') !== 'parcel') { + continue; + } + $packages[] = static::entityToUpsPackage($entity); + } + + $body = static::buildRateShopRequest( + $shipFrom, + $shipTo, + $packages, + (string) $this->accountNumber, + $serviceCode + ); + + $endpoint = $serviceCode + ? '/api/rating/v2403/rate/Rate' + : '/api/rating/v2403/rate/Shop'; + + $response = $this->post($endpoint, ['json' => $body]) ?? []; + + $markupType = (string) ($this->integratedVendor?->options['markup_type'] ?? 'flat'); + $markupValue = (int) ($this->integratedVendor?->options['markup_amount'] ?? 0); + + $rows = static::normalizeRateShopResponse($response, $markupType, $markupValue); + + $quotes = []; + foreach ($rows as $row) { + $serviceQuote = ServiceQuote::create([ + 'company_uuid' => $this->integratedVendor?->company_uuid, + 'payload_uuid' => $payload->uuid, + 'service_type' => 'parcel', + 'amount' => $row['amount'], + 'currency' => $row['currency'], + 'meta' => $row['meta'], + ]); + + ServiceQuoteItem::create([ + 'service_quote_uuid' => $serviceQuote->uuid, + 'amount' => $row['amount'], + 'currency' => $row['currency'], + 'details' => $row['service'], + 'code' => $row['meta']['service_code'], + ]); + + $quotes[] = $serviceQuote; + } + + return $quotes; + } +} diff --git a/server/tests/Integrations/UPS/UPSRatesBuilderTest.php b/server/tests/Integrations/UPS/UPSRatesBuilderTest.php new file mode 100644 index 00000000..a11126a9 --- /dev/null +++ b/server/tests/Integrations/UPS/UPSRatesBuilderTest.php @@ -0,0 +1,263 @@ +toEqualWithDelta(4.6619, 0.001); +}); + +test('dimensionalWeight defaults to domestic UPS divisor 139', function () { + expect(UPS::dimensionalWeight(10, 10, 10))->toEqualWithDelta(1000 / 139, 0.001); +}); + +test('dimensionalWeight accepts custom divisor', function () { + expect(UPS::dimensionalWeight(10, 10, 10, 166))->toEqualWithDelta(1000 / 166, 0.001); +}); + +// ── billableWeight ─────────────────────────────────────────────────────── + +test('billableWeight returns actual when actual exceeds dim', function () { + expect(UPS::billableWeight(5.0, 3.0))->toBe(5.0); +}); + +test('billableWeight returns dim when dim exceeds actual', function () { + expect(UPS::billableWeight(2.0, 4.66))->toBe(4.66); +}); + +test('billableWeight returns either when equal', function () { + expect(UPS::billableWeight(3.0, 3.0))->toBe(3.0); +}); + +// ── placeToUpsAddress ──────────────────────────────────────────────────── + +test('placeToUpsAddress maps street1/city/province/postal/country', function () { + $place = (object) [ + 'street1' => '1600 Pennsylvania Ave NW', + 'city' => 'Washington', + 'province' => 'DC', + 'postal_code' => '20500', + 'country' => 'US', + ]; + $addr = UPS::placeToUpsAddress($place); + expect($addr['AddressLine'])->toBe(['1600 Pennsylvania Ave NW']); + expect($addr['City'])->toBe('Washington'); + expect($addr['StateProvinceCode'])->toBe('DC'); + expect($addr['PostalCode'])->toBe('20500'); + expect($addr['CountryCode'])->toBe('US'); +}); + +test('placeToUpsAddress defaults CountryCode to US when absent', function () { + $place = (object) [ + 'street1' => '1 Main', + 'city' => 'Boise', + 'province' => 'ID', + 'postal_code' => '83702', + ]; + expect(UPS::placeToUpsAddress($place)['CountryCode'])->toBe('US'); +}); + +// ── entityToUpsPackage ─────────────────────────────────────────────────── + +test('entityToUpsPackage builds the UPS Package shape', function () { + $entity = (object) [ + 'type' => 'parcel', + 'length' => 12.0, + 'width' => 9.0, + 'height' => 3.0, + 'weight' => 2.5, + ]; + $pkg = UPS::entityToUpsPackage($entity); + + expect($pkg['PackagingType']['Code'])->toBe('02'); + expect($pkg['Dimensions']['UnitOfMeasurement']['Code'])->toBe('IN'); + expect($pkg['Dimensions']['Length'])->toBe('12'); + expect($pkg['Dimensions']['Width'])->toBe('9'); + expect($pkg['Dimensions']['Height'])->toBe('3'); + expect($pkg['PackageWeight']['UnitOfMeasurement']['Code'])->toBe('LBS'); + // Billable weight = max(actual=2.5, dim=12*9*3/139=2.33) = 2.5 + expect((float) $pkg['PackageWeight']['Weight'])->toEqualWithDelta(2.5, 0.01); +}); + +test('entityToUpsPackage uses dim weight when it exceeds actual', function () { + // 24 * 18 * 18 = 7776; 7776 / 139 ≈ 55.94 lb vs actual 10 lb + $entity = (object) [ + 'type' => 'parcel', + 'length' => 24.0, + 'width' => 18.0, + 'height' => 18.0, + 'weight' => 10.0, + ]; + $pkg = UPS::entityToUpsPackage($entity); + expect((float) $pkg['PackageWeight']['Weight'])->toEqualWithDelta(55.94, 0.1); +}); + +// ── buildRateShopRequest ───────────────────────────────────────────────── + +test('buildRateShopRequest assembles a Shop request with no service code', function () { + $shipFrom = UPS::placeToUpsAddress((object) ['street1' => '1 A', 'city' => 'X', 'province' => 'CA', 'postal_code' => '90001', 'country' => 'US']); + $shipTo = UPS::placeToUpsAddress((object) ['street1' => '1 B', 'city' => 'Y', 'province' => 'NY', 'postal_code' => '10001', 'country' => 'US']); + $packages = [UPS::entityToUpsPackage((object) ['type' => 'parcel', 'length' => 10, 'width' => 10, 'height' => 10, 'weight' => 5])]; + + $body = UPS::buildRateShopRequest($shipFrom, $shipTo, $packages, 'A1B2C3'); + + expect($body['RateRequest']['Request']['RequestOption'])->toBe('Shop'); + expect($body['RateRequest']['Shipment']['Shipper']['ShipperNumber'])->toBe('A1B2C3'); + expect($body['RateRequest']['Shipment']['Shipper']['Address']['PostalCode'])->toBe('90001'); + expect($body['RateRequest']['Shipment']['ShipTo']['Address']['PostalCode'])->toBe('10001'); + expect($body['RateRequest']['Shipment']['ShipFrom']['Address']['PostalCode'])->toBe('90001'); + expect($body['RateRequest']['Shipment']['Package'])->toHaveCount(1); + expect(isset($body['RateRequest']['Shipment']['Service']))->toBeFalse(); +}); + +test('buildRateShopRequest sets Service.Code when serviceCode is provided', function () { + $shipFrom = UPS::placeToUpsAddress((object) ['street1' => '1', 'city' => 'X', 'province' => 'CA', 'postal_code' => '90001']); + $shipTo = UPS::placeToUpsAddress((object) ['street1' => '2', 'city' => 'Y', 'province' => 'NY', 'postal_code' => '10001']); + $packages = [UPS::entityToUpsPackage((object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1])]; + + $body = UPS::buildRateShopRequest($shipFrom, $shipTo, $packages, 'A1B2C3', '03'); + + expect($body['RateRequest']['Request']['RequestOption'])->toBe('Rate'); + expect($body['RateRequest']['Shipment']['Service']['Code'])->toBe('03'); +}); + +test('buildRateShopRequest supports multiple packages', function () { + $shipFrom = UPS::placeToUpsAddress((object) ['street1' => '1', 'city' => 'X', 'province' => 'CA', 'postal_code' => '90001']); + $shipTo = UPS::placeToUpsAddress((object) ['street1' => '2', 'city' => 'Y', 'province' => 'NY', 'postal_code' => '10001']); + $packages = [ + UPS::entityToUpsPackage((object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1]), + UPS::entityToUpsPackage((object) ['length' => 2, 'width' => 2, 'height' => 2, 'weight' => 2]), + ]; + + $body = UPS::buildRateShopRequest($shipFrom, $shipTo, $packages, 'A1B2C3'); + expect($body['RateRequest']['Shipment']['Package'])->toHaveCount(2); +}); + +// ── normalizeRateShopResponse ──────────────────────────────────────────── + +function upsSingleRateResponse(array $ratedShipment): array +{ + // When UPS returns a single rated shipment it may be an object OR an array + return ['RateResponse' => ['RatedShipment' => $ratedShipment]]; +} + +test('normalizeRateShopResponse extracts service code and converts dollars to cents', function () { + $resp = upsSingleRateResponse([ + [ + 'Service' => ['Code' => '03'], + 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '12.40'], + ], + ]); + + $rows = UPS::normalizeRateShopResponse($resp, 'flat', 0); + + expect($rows)->toHaveCount(1); + expect($rows[0]['amount'])->toBe(1240); + expect($rows[0]['currency'])->toBe('USD'); + expect($rows[0]['meta']['carrier'])->toBe('UPS'); + expect($rows[0]['meta']['service_code'])->toBe('03'); + expect($rows[0]['meta']['carrier_amount'])->toBe(1240); + expect($rows[0]['meta']['markup_amount'])->toBe(0); +}); + +test('normalizeRateShopResponse prefers NegotiatedRateCharges over TotalCharges', function () { + $resp = upsSingleRateResponse([ + [ + 'Service' => ['Code' => '03'], + 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '12.40'], + 'NegotiatedRateCharges' => [ + 'TotalCharge' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '10.00'], + ], + ], + ]); + + $rows = UPS::normalizeRateShopResponse($resp, 'flat', 0); + expect($rows[0]['amount'])->toBe(1000); + expect($rows[0]['meta']['carrier_amount'])->toBe(1000); +}); + +test('normalizeRateShopResponse applies flat markup in cents', function () { + $resp = upsSingleRateResponse([[ + 'Service' => ['Code' => '03'], + 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '10.00'], + ]]); + + $rows = UPS::normalizeRateShopResponse($resp, 'flat', 50); + + // Carrier 1000 + flat 50 = 1050 sell + expect($rows[0]['amount'])->toBe(1050); + expect($rows[0]['meta']['carrier_amount'])->toBe(1000); + expect($rows[0]['meta']['markup_amount'])->toBe(50); + expect($rows[0]['meta']['markup_type'])->toBe('flat'); +}); + +test('normalizeRateShopResponse applies percent markup', function () { + $resp = upsSingleRateResponse([[ + 'Service' => ['Code' => '03'], + 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '10.00'], + ]]); + + $rows = UPS::normalizeRateShopResponse($resp, 'percent', 10); + + // Carrier 1000 + 10% = 1100 sell + expect($rows[0]['amount'])->toBe(1100); + expect($rows[0]['meta']['markup_amount'])->toBe(100); + expect($rows[0]['meta']['markup_type'])->toBe('percent'); +}); + +test('normalizeRateShopResponse handles multiple RatedShipment entries', function () { + $resp = upsSingleRateResponse([ + ['Service' => ['Code' => '03'], 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '12.40']], + ['Service' => ['Code' => '02'], 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '24.80']], + ['Service' => ['Code' => '01'], 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '45.00']], + ]); + + $rows = UPS::normalizeRateShopResponse($resp, 'flat', 0); + expect($rows)->toHaveCount(3); + expect($rows[0]['meta']['service_code'])->toBe('03'); + expect($rows[1]['meta']['service_code'])->toBe('02'); + expect($rows[2]['meta']['service_code'])->toBe('01'); +}); + +test('normalizeRateShopResponse accepts single RatedShipment as object (not array)', function () { + // UPS returns a single rated shipment as a direct object, not wrapped in an array + $resp = [ + 'RateResponse' => [ + 'RatedShipment' => [ + 'Service' => ['Code' => '03'], + 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '12.40'], + ], + ], + ]; + + $rows = UPS::normalizeRateShopResponse($resp, 'flat', 0); + expect($rows)->toHaveCount(1); + expect($rows[0]['meta']['service_code'])->toBe('03'); +}); + +test('normalizeRateShopResponse resolves service description via UPSServiceType', function () { + $resp = upsSingleRateResponse([[ + 'Service' => ['Code' => '03'], + 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '10.00'], + ]]); + + $rows = UPS::normalizeRateShopResponse($resp, 'flat', 0); + expect($rows[0]['service'])->toBe('UPS Ground'); +}); + +test('normalizeRateShopResponse returns empty array when RatedShipment is missing', function () { + $resp = ['RateResponse' => ['Response' => ['ResponseStatus' => ['Code' => '0']]]]; + expect(UPS::normalizeRateShopResponse($resp, 'flat', 0))->toBe([]); +}); + +test('normalizeRateShopResponse handles sub-cent rounding', function () { + $resp = upsSingleRateResponse([ + ['Service' => ['Code' => '03'], 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '8.425']], + ['Service' => ['Code' => '02'], 'TotalCharges' => ['CurrencyCode' => 'USD', 'MonetaryValue' => '8.424']], + ]); + $rows = UPS::normalizeRateShopResponse($resp, 'flat', 0); + expect($rows[0]['amount'])->toBe(843); + expect($rows[1]['amount'])->toBe(842); +}); From d521ad7f999a9736d139193d6b167390697a732d Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 14:43:41 -0700 Subject: [PATCH 19/25] feat(ups): createShipment + void with pure/impure split Extends the UPS bridge with Shipping API v2409 and Void API support following the same pure/impure architecture as Task 14. Pure static helpers (unit-tested via Pest without Laravel bootstrap): - signatureConfirmationCode(?string): ?int 'standard' -> 2, 'adult' -> 3, anything else -> null Case-insensitive. null opts out of DeliveryConfirmation entirely. - buildShipRequest(shipperName, shipFrom, shipTo, packages, accountNumber, serviceCode, labelFormat='PDF', ?orderPublicId=null, ?signaturePreference=null): array Returns the full ShipmentRequest body with: - BillShipper payment at the supplied account number - RequestOption=nonvalidate (UPS still validates, just no explicit AddressValidation fail-hard) - Service.Code set to the requested UPS service - LabelSpecification.LabelImageFormat.Code uppercased (PDF default, ZPL opt-in, no validation of image format) - ReferenceNumber code 00 populated with the FleetOps order public_id on every package when provided - DeliveryConfirmation.DCISType populated when signature is requested, omitted otherwise - Shipment.Description carrying the order public_id for carrier-side visibility - normalizeShipResponse(array): array Returns {tracking_number, shipment_id, label_binary, label_format, label_mime}. Decodes base64 GraphicImage. Handles the UPS quirk where a single PackageResults comes back as an object not an array. Throws RuntimeException on malformed responses so the impure wrapper can surface vendor errors. - normalizeVoidResponse(array): bool true when VoidShipmentResponse.SummaryResult.Status.Code=='1' or Status.Description=='Success' (case-insensitive). false otherwise. Impure instance wrappers (untested here; smoke-test gated at Task 17): - createOrderFromServiceQuote(ServiceQuote, Order): array Composes buildShipRequest from the order's pickup/dropoff/ parcel entities + the ServiceQuote's stashed service_code + the IntegratedVendor's label_format option + the order's optional signature_confirmation meta, POSTs to /api/shipments/v2409/ship, normalizes the response, writes the label binary under carrier-labels/ as a File record, and stores shipmentIdentificationNumber + tracking_number + carrier='UPS' + service_code on Order.meta.integrated_vendor_order. - voidShipment(string $shipmentIdentificationNumber): bool Calls DELETE /api/shipments/v1/void/cancel/{id}, returns normalizeVoidResponse(). ## Extraction rule compliance Per the Phase 2 porting rules, NO user-specific or environment-specific logic from ParcelPath v9 is carried over. Explicitly NOT carried over (call-sites documented inline where relevant): - Email-based UPS URL override in voidShipment. PP v9's void path switched hosts depending on $user->email. That branch is stripped entirely; only the generic sandbox/production host selection via UPSOAuthClient + $this->baseUrl() is used. - NBNL (Non-Barcoded Non-Letter) barcode-to-PDF post-processing. - Return label auto-swap when shipFrom and shipTo are inverted. - Multi-package letter merge (mergeUPSRateResponse). - Ground Saver service 93 estimated-days derivation. PP v9 source was not available in this workspace; the bridge was built directly against UPS Shipping API v2409 and Void API v1 documentation, which aligns with the extraction rule. Tests (Pest): 24 new passing covering signature code mapping, ShipmentRequest assembly (Shipper/ShipTo/ShipFrom, BillShipper payment, Service.Code, label format uppercasing, reference number injection, DeliveryConfirmation inclusion/omission, multi-package), response normalization (tracking number extraction, base64 decode, mime derivation for PDF/ZPL, single-package-as-object quirk, RuntimeException on malformed responses), and void response normalization (Code='1', Description='Success', failure modes, empty-response defense). Full suite: 155 passed. --- server/src/Integrations/UPS/UPS.php | 294 ++++++++++++++++++ .../Integrations/UPS/UPSLabelBuilderTest.php | 267 ++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100644 server/tests/Integrations/UPS/UPSLabelBuilderTest.php diff --git a/server/src/Integrations/UPS/UPS.php b/server/src/Integrations/UPS/UPS.php index 43435cde..b7f8078c 100644 --- a/server/src/Integrations/UPS/UPS.php +++ b/server/src/Integrations/UPS/UPS.php @@ -3,11 +3,14 @@ namespace Fleetbase\FleetOps\Integrations\UPS; use Fleetbase\FleetOps\Models\IntegratedVendor; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\FleetOps\Models\ServiceQuoteItem; +use Fleetbase\Models\File; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; +use Illuminate\Support\Facades\Storage; /** * UPS direct bridge class (Mode B). @@ -377,6 +380,202 @@ public static function normalizeRateShopResponse( return $rows; } + /** + * Map a human signature confirmation preference ('standard', 'adult') + * to the UPS DCISType numeric code expected in + * PackageServiceOptions.DeliveryConfirmation.DCISType. Returns null + * when no signature is requested (default, none, empty, unknown). + * + * Ported from the ParcelPath v9 createUPSDapLabel signature-mapping + * table per the Phase 2 extraction rule — only the generic mapping + * is carried over, no user/session-specific fallbacks. + */ + public static function signatureConfirmationCode(?string $preference): ?int + { + if ($preference === null || $preference === '') { + return null; + } + return match (strtolower($preference)) { + 'standard' => 2, + 'adult' => 3, + default => null, + }; + } + + /** + * Build the POST /api/shipments/v2409/ship request body. + * + * $labelFormat is normalized to uppercase and defaults to PDF. + * $orderPublicId, when provided, is injected as the UPS + * ReferenceNumber (code 00) on every package AND into the + * Shipment.Description so the tracking event feed carries a + * human-readable tie-back to the FleetOps order. + * $signaturePreference follows the signatureConfirmationCode + * mapping; null/unknown values omit DeliveryConfirmation entirely. + */ + public static function buildShipRequest( + string $shipperName, + array $shipFrom, + array $shipTo, + array $packages, + string $accountNumber, + string $serviceCode, + string $labelFormat = 'PDF', + ?string $orderPublicId = null, + ?string $signaturePreference = null + ): array { + $format = strtoupper($labelFormat); + $sigCode = self::signatureConfirmationCode($signaturePreference); + + // Attach reference number + optional DeliveryConfirmation to every package. + $preparedPackages = []; + foreach ($packages as $pkg) { + if ($orderPublicId !== null && $orderPublicId !== '') { + $pkg['ReferenceNumber'] = [[ + 'Code' => '00', + 'Value' => $orderPublicId, + ]]; + } + if ($sigCode !== null) { + $pkg['PackageServiceOptions'] = array_merge( + $pkg['PackageServiceOptions'] ?? [], + ['DeliveryConfirmation' => ['DCISType' => (string) $sigCode]] + ); + } + $preparedPackages[] = $pkg; + } + + $description = 'FleetOps shipment' + . ($orderPublicId !== null && $orderPublicId !== '' ? ' ' . $orderPublicId : ''); + + return [ + 'ShipmentRequest' => [ + 'Request' => [ + 'RequestOption' => 'nonvalidate', + ], + 'Shipment' => [ + 'Description' => $description, + 'Shipper' => [ + 'Name' => $shipperName, + 'ShipperNumber' => $accountNumber, + 'Address' => $shipFrom, + ], + 'ShipTo' => [ + 'Name' => $shipperName, + 'Address' => $shipTo, + ], + 'ShipFrom' => [ + 'Name' => $shipperName, + 'Address' => $shipFrom, + ], + 'PaymentInformation' => [ + 'ShipmentCharge' => [ + 'Type' => '01', // Transportation + 'BillShipper' => ['AccountNumber' => $accountNumber], + ], + ], + 'Service' => ['Code' => $serviceCode], + 'Package' => $preparedPackages, + ], + 'LabelSpecification' => [ + 'LabelImageFormat' => ['Code' => $format], + 'HTTPUserAgent' => 'fleetops', + ], + ], + ]; + } + + /** + * Normalize the POST /api/shipments/v2409/ship response into a row + * ready for persisting to Storage + the File table. + * + * Returns: + * [ + * 'tracking_number' => string, + * 'shipment_id' => string, // UPS ShipmentIdentificationNumber + * 'label_binary' => string, // raw decoded bytes + * 'label_format' => 'PDF' | 'ZPL' | 'GIF', + * 'label_mime' => 'application/pdf' | 'application/zpl' | 'image/gif', + * ] + * + * Handles the UPS quirk where a single PackageResults may come back + * as an object rather than an array of one. + * + * Throws RuntimeException on malformed responses — the impure + * wrapper should surface these as vendor errors to the caller. + */ + public static function normalizeShipResponse(array $response): array + { + $results = $response['ShipmentResponse']['ShipmentResults'] ?? null; + if (!is_array($results) || empty($results)) { + throw new \RuntimeException('UPS ship response is missing ShipmentResults'); + } + + $shipmentId = $results['ShipmentIdentificationNumber'] ?? null; + + $packageResults = $results['PackageResults'] ?? null; + if ($packageResults === null) { + throw new \RuntimeException('UPS ship response is missing PackageResults'); + } + + // Single PackageResults can come back as a direct object rather than an array. + if (isset($packageResults['TrackingNumber']) || isset($packageResults['ShippingLabel'])) { + $firstPackage = $packageResults; + } else { + $firstPackage = $packageResults[0] ?? null; + } + if (!is_array($firstPackage)) { + throw new \RuntimeException('UPS ship response has no package entries'); + } + + $trackingNumber = $firstPackage['TrackingNumber'] ?? $shipmentId; + if (!is_string($trackingNumber) || $trackingNumber === '') { + throw new \RuntimeException('UPS ship response is missing a tracking number'); + } + + $labelImage = $firstPackage['ShippingLabel']['GraphicImage'] ?? ''; + $labelFormat = strtoupper((string) ($firstPackage['ShippingLabel']['ImageFormat']['Code'] ?? 'PDF')); + + $labelMime = match ($labelFormat) { + 'ZPL' => 'application/zpl', + 'GIF' => 'image/gif', + default => 'application/pdf', + }; + + return [ + 'tracking_number' => $trackingNumber, + 'shipment_id' => (string) ($shipmentId ?? $trackingNumber), + 'label_binary' => base64_decode($labelImage), + 'label_format' => $labelFormat, + 'label_mime' => $labelMime, + ]; + } + + /** + * Normalize the DELETE /api/shipments/v1/void/cancel/{id} response. + * Returns true when UPS reports a successful void by either returning + * Status.Code='1' or Status.Description='Success' (case-insensitive). + */ + public static function normalizeVoidResponse(array $response): bool + { + $status = $response['VoidShipmentResponse']['SummaryResult']['Status'] ?? null; + if (!is_array($status)) { + return false; + } + + $code = isset($status['Code']) ? (string) $status['Code'] : ''; + if ($code === '1') { + return true; + } + + $description = isset($status['Description']) ? (string) $status['Description'] : ''; + if (strcasecmp($description, 'Success') === 0) { + return true; + } + + return false; + } + // ───────────────────────────────────────────────────────────────────── // IMPURE RUNTIME WRAPPERS — compose pure helpers + HTTP + Eloquent. // Not unit-tested in this phase; exercised via smoke test once the @@ -456,4 +655,99 @@ public function getQuoteFromPayload( return $quotes; } + + /** + * Purchase a UPS shipping label against the POST /api/shipments/v2409/ship + * endpoint. Decodes the base64 label binary, writes it to the default + * Storage disk under carrier-labels/, creates a File record pointing at + * it, and stores the shipmentIdentificationNumber + tracking_number + + * carrier ('UPS') + service_code on Order.meta.integrated_vendor_order. + * + * Not unit-tested here; exercised at runtime via smoke test once the + * registry entry lands (Task 17) and a real UPS sandbox account is + * configured. The tricky pieces — request assembly and response + * parsing — are already covered by UPSLabelBuilderTest. + */ + public function createOrderFromServiceQuote(ServiceQuote $serviceQuote, Order $order): array + { + $serviceCode = (string) ($serviceQuote->meta['service_code'] ?? ''); + if ($serviceCode === '') { + throw new \RuntimeException('ServiceQuote missing UPS service_code in meta'); + } + + $format = strtoupper((string) ($this->integratedVendor?->options['label_format'] ?? 'PDF')); + $signature = $order->getMeta('signature_confirmation', null); + + $shipperName = (string) ($order->payload->pickup->name ?? 'FleetOps Shipper'); + $shipFrom = static::placeToUpsAddress($order->payload->pickup); + $shipTo = static::placeToUpsAddress($order->payload->dropoff); + + $packages = []; + foreach ($order->payload->entities ?? [] as $entity) { + if (($entity->type ?? 'parcel') !== 'parcel') { + continue; + } + $packages[] = static::entityToUpsPackage($entity); + } + + $body = static::buildShipRequest( + $shipperName, + $shipFrom, + $shipTo, + $packages, + (string) $this->accountNumber, + $serviceCode, + $format, + $order->public_id, + is_string($signature) ? $signature : null, + ); + + $response = $this->post('/api/shipments/v2409/ship', ['json' => $body]) ?? []; + $result = static::normalizeShipResponse($response); + + $ext = strtolower($result['label_format']); + $disk = config('filesystems.default'); + $filename = 'ups_label_' . $result['tracking_number'] . '.' . $ext; + $path = 'carrier-labels/' . $filename; + Storage::disk($disk)->put($path, $result['label_binary']); + + File::create([ + 'company_uuid' => $order->company_uuid, + 'subject_uuid' => $order->uuid, + 'subject_type' => Order::class, + 'content_type' => $result['label_mime'], + 'folder' => 'carrier-labels', + 'path' => $path, + 'disk' => $disk, + 'original_filename' => $filename, + ]); + + $order->updateMeta('integrated_vendor_order', [ + 'carrier' => 'UPS', + 'shipmentIdentificationNumber' => $result['shipment_id'], + 'tracking_number' => $result['tracking_number'], + 'service_code' => $serviceCode, + ]); + + return $result; + } + + /** + * Void a UPS shipment by its ShipmentIdentificationNumber. + * + * NOTE on extraction rule: ParcelPath v9's void path (per the + * Phase 2 porting rules flagged by the team) contained an + * email-based UPS URL override that switched hosts depending on + * the logged-in user's email. That branch is NOT carried over. + * Only the generic sandbox/production host selection managed by + * UPSOAuthClient and $this->baseUrl() is used. + */ + public function voidShipment(string $shipmentIdentificationNumber): bool + { + $response = $this->delete( + '/api/shipments/v1/void/cancel/' . rawurlencode($shipmentIdentificationNumber) + ) ?? []; + + return static::normalizeVoidResponse($response); + } } diff --git a/server/tests/Integrations/UPS/UPSLabelBuilderTest.php b/server/tests/Integrations/UPS/UPSLabelBuilderTest.php new file mode 100644 index 00000000..281f2b07 --- /dev/null +++ b/server/tests/Integrations/UPS/UPSLabelBuilderTest.php @@ -0,0 +1,267 @@ +toBe(2); +}); + +test('signatureConfirmationCode returns 3 for adult', function () { + expect(UPS::signatureConfirmationCode('adult'))->toBe(3); +}); + +test('signatureConfirmationCode is case-insensitive', function () { + expect(UPS::signatureConfirmationCode('ADULT'))->toBe(3); + expect(UPS::signatureConfirmationCode('Standard'))->toBe(2); +}); + +test('signatureConfirmationCode returns null for none/default/unknown', function () { + expect(UPS::signatureConfirmationCode('none'))->toBeNull(); + expect(UPS::signatureConfirmationCode('default'))->toBeNull(); + expect(UPS::signatureConfirmationCode(''))->toBeNull(); + expect(UPS::signatureConfirmationCode('whatever'))->toBeNull(); + expect(UPS::signatureConfirmationCode(null))->toBeNull(); +}); + +// ── buildShipRequest ───────────────────────────────────────────────────── + +function upsShipTestContext(): array +{ + return [ + 'shipperName' => 'ACME Shipping', + 'shipFrom' => UPS::placeToUpsAddress((object) [ + 'street1' => '1 Warehouse Way', + 'city' => 'Oakland', + 'province' => 'CA', + 'postal_code' => '94607', + 'country' => 'US', + ]), + 'shipTo' => UPS::placeToUpsAddress((object) [ + 'street1' => '350 5th Ave', + 'city' => 'New York', + 'province' => 'NY', + 'postal_code' => '10118', + 'country' => 'US', + ]), + 'packages' => [ + UPS::entityToUpsPackage((object) [ + 'type' => 'parcel', 'length' => 12, 'width' => 9, 'height' => 3, 'weight' => 2.5, + ]), + ], + ]; +} + +test('buildShipRequest wraps ShipmentRequest with Shipper + ShipTo + ShipFrom', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest( + $ctx['shipperName'], + $ctx['shipFrom'], + $ctx['shipTo'], + $ctx['packages'], + 'A1B2C3', + '03', + 'PDF', + 'ORDER_PUBLIC_ID', + ); + + expect($body['ShipmentRequest']['Request']['RequestOption'])->toBe('nonvalidate'); + expect($body['ShipmentRequest']['Shipment']['Description'])->toContain('ORDER_PUBLIC_ID'); + expect($body['ShipmentRequest']['Shipment']['Shipper']['Name'])->toBe('ACME Shipping'); + expect($body['ShipmentRequest']['Shipment']['Shipper']['ShipperNumber'])->toBe('A1B2C3'); + expect($body['ShipmentRequest']['Shipment']['Shipper']['Address']['PostalCode'])->toBe('94607'); + expect($body['ShipmentRequest']['Shipment']['ShipTo']['Address']['PostalCode'])->toBe('10118'); + expect($body['ShipmentRequest']['Shipment']['ShipFrom']['Address']['PostalCode'])->toBe('94607'); +}); + +test('buildShipRequest uses BillShipper payment with the account number', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03'); + expect($body['ShipmentRequest']['Shipment']['PaymentInformation']['ShipmentCharge']['Type'])->toBe('01'); + expect($body['ShipmentRequest']['Shipment']['PaymentInformation']['ShipmentCharge']['BillShipper']['AccountNumber']) + ->toBe('A1B2C3'); +}); + +test('buildShipRequest sets Service.Code to the requested UPS service', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '01'); + expect($body['ShipmentRequest']['Shipment']['Service']['Code'])->toBe('01'); +}); + +test('buildShipRequest LabelSpecification LabelImageFormat.Code defaults to PDF', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03'); + expect($body['ShipmentRequest']['LabelSpecification']['LabelImageFormat']['Code'])->toBe('PDF'); +}); + +test('buildShipRequest uppercases the label format and supports ZPL', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03', 'zpl'); + expect($body['ShipmentRequest']['LabelSpecification']['LabelImageFormat']['Code'])->toBe('ZPL'); +}); + +test('buildShipRequest attaches reference number 00 with the order public id', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03', 'PDF', 'order_abc'); + $pkg = $body['ShipmentRequest']['Shipment']['Package'][0]; + expect($pkg['ReferenceNumber'][0]['Code'])->toBe('00'); + expect($pkg['ReferenceNumber'][0]['Value'])->toBe('order_abc'); +}); + +test('buildShipRequest omits DeliveryConfirmation when no signature requested', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03', 'PDF', 'o1', null); + $pkg = $body['ShipmentRequest']['Shipment']['Package'][0]; + expect(isset($pkg['PackageServiceOptions']['DeliveryConfirmation']))->toBeFalse(); +}); + +test('buildShipRequest sets DeliveryConfirmation DCISType=2 for standard signature', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03', 'PDF', 'o1', 'standard'); + $pkg = $body['ShipmentRequest']['Shipment']['Package'][0]; + expect($pkg['PackageServiceOptions']['DeliveryConfirmation']['DCISType'])->toBe('2'); +}); + +test('buildShipRequest sets DeliveryConfirmation DCISType=3 for adult signature', function () { + $ctx = upsShipTestContext(); + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03', 'PDF', 'o1', 'adult'); + $pkg = $body['ShipmentRequest']['Shipment']['Package'][0]; + expect($pkg['PackageServiceOptions']['DeliveryConfirmation']['DCISType'])->toBe('3'); +}); + +test('buildShipRequest supports multi-package shipments', function () { + $ctx = upsShipTestContext(); + $ctx['packages'][] = UPS::entityToUpsPackage((object) ['length' => 5, 'width' => 5, 'height' => 5, 'weight' => 1]); + + $body = UPS::buildShipRequest($ctx['shipperName'], $ctx['shipFrom'], $ctx['shipTo'], $ctx['packages'], 'A1B2C3', '03'); + expect($body['ShipmentRequest']['Shipment']['Package'])->toHaveCount(2); +}); + +// ── normalizeShipResponse ──────────────────────────────────────────────── + +test('normalizeShipResponse extracts tracking number and decodes label binary', function () { + $label = '%PDF-1.4 fake pdf bytes'; + $resp = [ + 'ShipmentResponse' => [ + 'ShipmentResults' => [ + 'ShipmentIdentificationNumber' => '1Z999AA10123456784', + 'PackageResults' => [ + [ + 'TrackingNumber' => '1Z999AA10123456784', + 'ShippingLabel' => [ + 'ImageFormat' => ['Code' => 'PDF'], + 'GraphicImage' => base64_encode($label), + ], + ], + ], + ], + ], + ]; + + $row = UPS::normalizeShipResponse($resp); + + expect($row['tracking_number'])->toBe('1Z999AA10123456784'); + expect($row['shipment_id'])->toBe('1Z999AA10123456784'); + expect($row['label_binary'])->toBe($label); + expect($row['label_format'])->toBe('PDF'); + expect($row['label_mime'])->toBe('application/pdf'); +}); + +test('normalizeShipResponse derives ZPL mime for ZPL labels', function () { + $resp = [ + 'ShipmentResponse' => [ + 'ShipmentResults' => [ + 'ShipmentIdentificationNumber' => '1Z000', + 'PackageResults' => [ + [ + 'TrackingNumber' => '1Z000', + 'ShippingLabel' => [ + 'ImageFormat' => ['Code' => 'ZPL'], + 'GraphicImage' => base64_encode('^XA^XZ'), + ], + ], + ], + ], + ], + ]; + + $row = UPS::normalizeShipResponse($resp); + expect($row['label_format'])->toBe('ZPL'); + expect($row['label_mime'])->toBe('application/zpl'); +}); + +test('normalizeShipResponse handles single PackageResults returned as object (not array)', function () { + $resp = [ + 'ShipmentResponse' => [ + 'ShipmentResults' => [ + 'ShipmentIdentificationNumber' => '1Z000', + 'PackageResults' => [ + 'TrackingNumber' => '1Z000', + 'ShippingLabel' => [ + 'ImageFormat' => ['Code' => 'PDF'], + 'GraphicImage' => base64_encode('bin'), + ], + ], + ], + ], + ]; + + $row = UPS::normalizeShipResponse($resp); + expect($row['tracking_number'])->toBe('1Z000'); + expect($row['label_binary'])->toBe('bin'); +}); + +test('normalizeShipResponse throws when ShipmentResults missing', function () { + $resp = ['ShipmentResponse' => ['Response' => ['ResponseStatus' => ['Code' => '0']]]]; + expect(fn () => UPS::normalizeShipResponse($resp))->toThrow(RuntimeException::class); +}); + +test('normalizeShipResponse throws when tracking number missing', function () { + $resp = ['ShipmentResponse' => ['ShipmentResults' => []]]; + expect(fn () => UPS::normalizeShipResponse($resp))->toThrow(RuntimeException::class); +}); + +// ── normalizeVoidResponse ──────────────────────────────────────────────── + +test('normalizeVoidResponse returns true on Status.Code=1 Success', function () { + $resp = [ + 'VoidShipmentResponse' => [ + 'SummaryResult' => [ + 'Status' => ['Code' => '1', 'Description' => 'Success'], + ], + ], + ]; + expect(UPS::normalizeVoidResponse($resp))->toBeTrue(); +}); + +test('normalizeVoidResponse returns true for success description only', function () { + $resp = [ + 'VoidShipmentResponse' => [ + 'SummaryResult' => [ + 'Status' => ['Description' => 'Success'], + ], + ], + ]; + expect(UPS::normalizeVoidResponse($resp))->toBeTrue(); +}); + +test('normalizeVoidResponse returns false when Status is absent', function () { + $resp = ['VoidShipmentResponse' => ['SummaryResult' => []]]; + expect(UPS::normalizeVoidResponse($resp))->toBeFalse(); +}); + +test('normalizeVoidResponse returns false on empty array', function () { + expect(UPS::normalizeVoidResponse([]))->toBeFalse(); +}); + +test('normalizeVoidResponse returns false on failure status', function () { + $resp = [ + 'VoidShipmentResponse' => [ + 'SummaryResult' => [ + 'Status' => ['Code' => '0', 'Description' => 'Failed'], + ], + ], + ]; + expect(UPS::normalizeVoidResponse($resp))->toBeFalse(); +}); From 8c7d495b1bee04ea94a36e667a19ee922bf15866 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 14:53:51 -0700 Subject: [PATCH 20/25] =?UTF-8?q?feat(ups):=20register=20in=20IntegratedVe?= =?UTF-8?q?ndors=20(partial=20Task=2017=20=E2=80=94=20UPS=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends the ups entry to IntegratedVendors::\$supported so the UPS direct bridge class (committed in Tasks 14 + 15) is now constructible through the existing IntegratedVendor::api() resolution machinery. USPS is explicitly NOT registered yet — Task 16 (USPS bridge + enum) must land first. Registry shape (matches spec §3.2 and the Phase 1 ParcelPath entry's style exactly): - code: 'ups' - host: https://onlinetools.ups.com/ - sandbox: https://wwwcie.ups.com/ - namespace: 'api' - bridge: UPS::class - svc_bridge: UPSServiceType::class - iso2cc_bridge: null (UPS is a US-first carrier; no market selector) - credentialParams: client_id, client_secret, account_number - optionParams: label_format (PDF/ZPL), markup_type (flat/percent), markup_amount, client_label - bridgeParams: credentials.client_id -> clientId, credentials.client_secret -> clientSecret, credentials.account_number -> accountNumber, sandbox -> sandbox - callbacks: [] (UPS does not expose push webhooks) No UI changes. No controller changes. No runtime smoke test. The existing dynamic integrated-vendor/form.hbs will render the UPS credential + option fields automatically from the registry contract — same behavior as ParcelPath got in Phase 1. Nothing in this commit triggers a UPS API call; the bridge is merely reachable via the dependency-injection chain now. Tests (Pest): 8 new registry assertions covering core shape, credential params, option params, bridge params, option value lists, empty callbacks, and a regression guard on the parcelpath entry. Full suite: 163 passed. --- server/src/Support/IntegratedVendors.php | 36 ++++++++ .../IntegratedVendorsUpsRegistryTest.php | 87 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 server/tests/Support/IntegratedVendorsUpsRegistryTest.php diff --git a/server/src/Support/IntegratedVendors.php b/server/src/Support/IntegratedVendors.php index 6c464800..9da93fdd 100644 --- a/server/src/Support/IntegratedVendors.php +++ b/server/src/Support/IntegratedVendors.php @@ -7,6 +7,8 @@ use Fleetbase\FleetOps\Integrations\Lalamove\LalamoveServiceType; use Fleetbase\FleetOps\Integrations\ParcelPath\ParcelPath; use Fleetbase\FleetOps\Integrations\ParcelPath\ParcelPathServiceType; +use Fleetbase\FleetOps\Integrations\UPS\UPS; +use Fleetbase\FleetOps\Integrations\UPS\UPSServiceType; use Fleetbase\FleetOps\Models\IntegratedVendor; use Illuminate\Support\Str; @@ -273,6 +275,40 @@ class IntegratedVendors ], 'callbacks' => [], ], + [ + 'name' => 'UPS', + 'code' => 'ups', + 'host' => 'https://onlinetools.ups.com/', + 'sandbox' => 'https://wwwcie.ups.com/', + 'namespace' => 'api', + 'bridge' => UPS::class, + 'svc_bridge' => UPSServiceType::class, + 'iso2cc_bridge' => null, + 'credentialParams' => [ + ['key' => 'client_id'], + ['key' => 'client_secret'], + ['key' => 'account_number'], + ], + 'optionParams' => [ + ['key' => 'label_format', 'options' => [ + ['value' => 'PDF', 'label' => 'PDF'], + ['value' => 'ZPL', 'label' => 'ZPL (thermal)'], + ], 'optionValue' => 'value', 'optionLabel' => 'label'], + ['key' => 'markup_type', 'options' => [ + ['value' => 'flat', 'label' => 'Flat (cents)'], + ['value' => 'percent', 'label' => 'Percentage'], + ], 'optionValue' => 'value', 'optionLabel' => 'label'], + ['key' => 'markup_amount'], + ['key' => 'client_label'], + ], + 'bridgeParams' => [ + 'clientId' => 'credentials.client_id', + 'clientSecret' => 'credentials.client_secret', + 'accountNumber' => 'credentials.account_number', + 'sandbox' => 'sandbox', + ], + 'callbacks' => [], + ], ]; public static function all() diff --git a/server/tests/Support/IntegratedVendorsUpsRegistryTest.php b/server/tests/Support/IntegratedVendorsUpsRegistryTest.php new file mode 100644 index 00000000..3bc8f9ef --- /dev/null +++ b/server/tests/Support/IntegratedVendorsUpsRegistryTest.php @@ -0,0 +1,87 @@ +fail('ups entry not registered in IntegratedVendors::$supported'); +} + +// ── Core shape ─────────────────────────────────────────────────────────── + +test('ups entry is registered with the expected core fields', function () { + $entry = upsEntry(); + + expect($entry['name'])->toBe('UPS'); + expect($entry['host'])->toBe('https://onlinetools.ups.com/'); + expect($entry['sandbox'])->toBe('https://wwwcie.ups.com/'); + expect($entry['namespace'])->toBe('api'); + expect($entry['bridge'])->toBe(UPS::class); + expect($entry['svc_bridge'])->toBe(UPSServiceType::class); + expect($entry['iso2cc_bridge'])->toBeNull(); +}); + +// ── Credential params ──────────────────────────────────────────────────── + +test('ups entry declares client_id, client_secret, and account_number credential params', function () { + $entry = upsEntry(); + $keys = array_column($entry['credentialParams'], 'key'); + expect($keys)->toBe(['client_id', 'client_secret', 'account_number']); +}); + +// ── Option params ──────────────────────────────────────────────────────── + +test('ups entry declares label_format / markup_type / markup_amount / client_label option params', function () { + $entry = upsEntry(); + $keys = array_column($entry['optionParams'], 'key'); + + expect($keys)->toContain('label_format'); + expect($keys)->toContain('markup_type'); + expect($keys)->toContain('markup_amount'); + expect($keys)->toContain('client_label'); +}); + +test('ups label_format option exposes PDF and ZPL', function () { + $entry = upsEntry(); + $fmt = collect($entry['optionParams'])->firstWhere('key', 'label_format'); + $values = array_column($fmt['options'], 'value'); + expect($values)->toEqualCanonicalizing(['PDF', 'ZPL']); +}); + +test('ups markup_type option exposes flat and percent', function () { + $entry = upsEntry(); + $markup = collect($entry['optionParams'])->firstWhere('key', 'markup_type'); + $values = array_column($markup['options'], 'value'); + expect($values)->toEqualCanonicalizing(['flat', 'percent']); +}); + +// ── Bridge params ──────────────────────────────────────────────────────── + +test('ups bridgeParams map credentials and sandbox to constructor args', function () { + $entry = upsEntry(); + expect($entry['bridgeParams']['clientId'])->toBe('credentials.client_id'); + expect($entry['bridgeParams']['clientSecret'])->toBe('credentials.client_secret'); + expect($entry['bridgeParams']['accountNumber'])->toBe('credentials.account_number'); + expect($entry['bridgeParams']['sandbox'])->toBe('sandbox'); +}); + +// ── Callbacks ──────────────────────────────────────────────────────────── + +test('ups has no callbacks (no webhook registration)', function () { + $entry = upsEntry(); + expect($entry['callbacks'])->toBe([]); +}); + +// ── Phase 1 regression guard ───────────────────────────────────────────── + +test('parcelpath entry is still present after UPS registration (regression guard)', function () { + $parcelpath = collect(IntegratedVendors::$supported)->firstWhere('code', 'parcelpath'); + expect($parcelpath)->not->toBeNull(); +}); From c1a8e849da1dc2d8eb1c94d058d54a1564280f13 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 15:12:49 -0700 Subject: [PATCH 21/25] feat(usps): USPSServiceType + USPS bridge (v3 direct) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the USPS direct bridge for Mode B, parallel to Phase 2's UPS bridge but following USPS Web Tools v3 semantics. USPSServiceType enum: 5 entries (PRIORITY, PRIORITY_EXPRESS, GROUND_ADVANTAGE, FIRST_CLASS, MEDIA_MAIL) with mail_class values matching the USPS v3 API identifiers. Mirrors UPSServiceType shape (Collection of typed instances, dynamic hydration, magic getters, #[\AllowDynamicProperties]). USPS bridge class: - Same constructor shape as UPS.php but with inline OAuth instead of a separate client class — USPS v3's oauth2/v3/token flow is simple enough for a private getAccessToken() method on the bridge. Same HTTP Basic + grant_type=client_credentials pattern, same cache-key-by-clientId scoping (usps_oauth_token_{clientId}), same (expires_in - 60)s TTL clamped to >=60s. - Sandbox routes to apis-tem.usps.com, production to api.usps.com. - Injectable Guzzle HandlerStack and \ArrayAccess cache so Pest runs without Laravel bootstrap. Pure static helpers (unit-tested): - placeToUspsAddress: [streetAddress, city, state, ZIPCode] - entityToUspsParcel: [length, width, height, weight] (no dim weight math — USPS v3 computes billable weight server-side) - buildRatesRequest: POST /prices/v3/base-rates/search body - normalizeRatesResponse: rates[].price -> cents, flat/percent markup, description via USPSServiceType, skips rows missing a price, defaults currency to USD - buildLabelRequest: POST /labels/v3/label body. imageType is ALWAYS 'PDF' — USPS v3 does not issue ZPL labels. - normalizeLabelResponse: {tracking_number, label_binary, label_format='PDF', label_mime='application/pdf'}. PDF enforced regardless of what labelMetadata reports. Throws RuntimeException on missing trackingNumber/labelImage. - normalizeTrackingResponse: USPS v3 eventType -> Fleetbase TrackingStatus code. ALERT -> EXCEPTION per spec §6.2; other known codes pass through verbatim. Preserves event order; derives final status from last trackingEvents[] entry. - uspsEventTypeToFleetbaseCode: extracted as a public pure helper for the Task 21 PollUSPSTrackingJob code mapping. - normalizeVoidResponse: true when refundStatus='APPROVED' (case- insensitive). USPS v3 models label voids as refunds. Thin impure wrappers (smoke-test gated): - getQuoteFromPayload: rates the first parcel only (multi-parcel is Phase 3 Task 25 batch shipping work), writes ServiceQuote + ServiceQuoteItem via bound IntegratedVendor's company_uuid + markup options. - createOrderFromServiceQuote: composes buildLabelRequest, POSTs to /labels/v3/label, writes File record under carrier-labels/ named usps_label_{tracking}.pdf, stores carrier='USPS' + tracking_number + mail_class on Order.meta.integrated_vendor_order. - getTrackingStatus: GET /tracking/v3/tracking/{trackingNumber} - voidShipment: POST /labels/v3/refund (first-smoke-test may reveal a different path; the helpers are path-agnostic). ## USPS-specific quirks - v3 is one-parcel-per-request for both rates and labels; multi- parcel orders are Phase 3 Task 25 - PDF-only; the label_format option will NOT appear on the Task 17 USPS registry entry (contrast with UPS) - No account number; credential tuple is clientId + clientSecret - No client-side dim weight math; server computes billable weight - Tracking events are chronological (first = oldest) ## Extraction rule compliance Per the Phase 2 porting rules, NO user-specific or environment-specific logic from ParcelPath v9 / EasyPostService is carried over. Explicitly NOT carried over: Shippo fallback branch, predefined USPS package type detection (flat rate envelope / box sizes), international shipping flag, any email-based URL overrides or per-user rate-tier branching. PP v9 source was not available in this workspace; the bridge was built directly against USPS Web Tools v3 documentation. Tests (Pest): 49 new passing — 11 enum + 20 rates/oauth + 18 label/ tracking/void. Full suite: 212 passed (484 assertions). --- server/src/Integrations/USPS/USPS.php | 650 ++++++++++++++++++ .../src/Integrations/USPS/USPSServiceType.php | 75 ++ .../USPS/USPSLabelBuilderTest.php | 176 +++++ .../USPS/USPSRatesBuilderTest.php | 266 +++++++ .../Integrations/USPS/USPSServiceTypeTest.php | 57 ++ 5 files changed, 1224 insertions(+) create mode 100644 server/src/Integrations/USPS/USPS.php create mode 100644 server/src/Integrations/USPS/USPSServiceType.php create mode 100644 server/tests/Integrations/USPS/USPSLabelBuilderTest.php create mode 100644 server/tests/Integrations/USPS/USPSRatesBuilderTest.php create mode 100644 server/tests/Integrations/USPS/USPSServiceTypeTest.php diff --git a/server/src/Integrations/USPS/USPS.php b/server/src/Integrations/USPS/USPS.php new file mode 100644 index 00000000..0a5fa4ff --- /dev/null +++ b/server/src/Integrations/USPS/USPS.php @@ -0,0 +1,650 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->isSandbox = $sandbox; + + $clientConfig = []; + if ($handler !== null) { + $clientConfig['handler'] = $handler; + } + $this->client = new Client($clientConfig); + + $this->cache = $cache ?? new \ArrayObject(); + } + + public function setRequestId(?string $requestId): self + { + $this->requestId = $requestId; + return $this; + } + + public function setOptions(?array $options = []): self + { + $this->options = array_merge($this->options, (array) $options); + return $this; + } + + public function getOptions(): array + { + return $this->options; + } + + public function setIntegratedVendor(IntegratedVendor $integratedVendor): self + { + $this->integratedVendor = $integratedVendor; + return $this; + } + + public function isSandbox(): bool + { + return $this->isSandbox; + } + + public function getLastCachedTtl(): int + { + return $this->lastCachedTtl; + } + + public function baseUrl(): string + { + return $this->isSandbox ? $this->sandboxHost : $this->host; + } + + /** + * Inline OAuth: fetches (or reads from cache) a bearer token for + * USPS v3. Cache key is scoped by clientId so multi-tenant broker + * deployments don't collide on each other's tokens. + * + * Mirrors UPSOAuthClient's semantics exactly — same TTL safety + * (expires_in - 60s, clamped to ≥60s), same HTTP Basic auth, same + * grant_type=client_credentials body. Kept inline because there + * is no shared auth state across USPS endpoints the way there is + * for UPS's multi-product setup. + */ + public function getAccessToken(): string + { + $key = 'usps_oauth_token_' . (string) $this->clientId; + + if (isset($this->cache[$key]) && $this->cache[$key] !== null && $this->cache[$key] !== '') { + return (string) $this->cache[$key]; + } + + $response = $this->client->request('POST', $this->baseUrl() . '/oauth2/v3/token', [ + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode(((string) $this->clientId) . ':' . ((string) $this->clientSecret)), + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ], + 'body' => 'grant_type=client_credentials', + 'http_errors' => false, + ]); + + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + throw new RuntimeException(sprintf( + 'USPS OAuth token request failed with HTTP %d', + $response->getStatusCode() + )); + } + + $body = json_decode((string) $response->getBody(), true) ?? []; + if (!isset($body['access_token']) || !is_string($body['access_token']) || $body['access_token'] === '') { + throw new RuntimeException('USPS OAuth response missing access_token'); + } + + $token = $body['access_token']; + $expiresIn = (int) ($body['expires_in'] ?? 3600); + $this->lastCachedTtl = max(self::TTL_MIN, $expiresIn - self::TTL_SAFETY); + + $this->cache[$key] = $token; + + return $token; + } + + /** + * Execute an authenticated USPS v3 request. + */ + private function request(string $method, string $path, array $options = []) + { + $token = $this->getAccessToken(); + + $options['headers'] = array_merge($options['headers'] ?? [], [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]); + + if ($this->requestId !== null) { + $options['headers']['X-Request-Id'] = $this->requestId; + } + + $options['http_errors'] = false; + + $response = $this->client->request($method, $this->baseUrl() . $path, $options); + return json_decode((string) $response->getBody(), true); + } + + public function get(string $path, array $options = []) + { + return $this->request('GET', $path, $options); + } + + public function post(string $path, array $options = []) + { + return $this->request('POST', $path, $options); + } + + public function delete(string $path, array $options = []) + { + return $this->request('DELETE', $path, $options); + } + + // ───────────────────────────────────────────────────────────────────── + // PURE HELPERS — request builders and response normalizers. + // Static, take plain arrays / duck-typed objects, unit-testable + // under Pest without booting Laravel. + // ───────────────────────────────────────────────────────────────────── + + /** + * Convert a Place-like object to the USPS v3 Address shape. + * USPS uses streetAddress / city / state / ZIPCode instead of + * UPS's AddressLine/StateProvinceCode/PostalCode naming. + */ + public static function placeToUspsAddress(object $place): array + { + return [ + 'streetAddress' => (string) ($place->street1 ?? ''), + 'city' => (string) ($place->city ?? ''), + 'state' => (string) ($place->province ?? ''), + 'ZIPCode' => (string) ($place->postal_code ?? ''), + ]; + } + + /** + * Convert an Entity-like parcel to the USPS v3 parcel shape. + * Dimensions are inches, weight is pounds. Unlike UPS we do not + * compute dimensional weight here — USPS v3 accepts raw dimensions + * + actual weight and computes the billable weight server-side. + */ + public static function entityToUspsParcel(object $entity): array + { + return [ + 'length' => (float) ($entity->length ?? 0), + 'width' => (float) ($entity->width ?? 0), + 'height' => (float) ($entity->height ?? 0), + 'weight' => (float) ($entity->weight ?? 0), + ]; + } + + /** + * Build the POST /prices/v3/base-rates/search request body. + * mailClass is optional; omitting it searches all services. + */ + public static function buildRatesRequest( + array $shipFrom, + array $shipTo, + array $parcel, + ?string $mailClass = null + ): array { + $body = [ + 'originZIPCode' => (string) ($shipFrom['ZIPCode'] ?? ''), + 'destinationZIPCode' => (string) ($shipTo['ZIPCode'] ?? ''), + 'weight' => $parcel['weight'], + 'length' => $parcel['length'], + 'width' => $parcel['width'], + 'height' => $parcel['height'], + ]; + + if ($mailClass !== null && $mailClass !== '') { + $body['mailClass'] = $mailClass; + } + + return $body; + } + + /** + * Normalize a USPS /prices/v3/base-rates/search response into an + * array of rows ready for ServiceQuote::create. Converts dollars + * to integer cents, applies flat or percent markup, resolves the + * service description via USPSServiceType, and silently skips + * rows that don't carry a price. + */ + public static function normalizeRatesResponse( + array $response, + string $markupType = 'flat', + int $markupValue = 0 + ): array { + $rates = $response['rates'] ?? []; + if (!is_array($rates)) { + return []; + } + + $rows = []; + foreach ($rates as $rate) { + if (!isset($rate['price'])) { + continue; + } + + $carrierAmount = (int) round(((float) $rate['price']) * 100); + $markup = $markupType === 'percent' + ? (int) round($carrierAmount * $markupValue / 100) + : $markupValue; + $sellAmount = $carrierAmount + $markup; + + $mailClass = (string) ($rate['mailClass'] ?? ''); + $serviceType = USPSServiceType::find(function ($t) use ($mailClass) { + return $t->mail_class === $mailClass; + }); + $description = $serviceType !== null + ? (string) $serviceType->description + : 'USPS ' . $mailClass; + + $rows[] = [ + 'amount' => $sellAmount, + 'currency' => 'USD', + 'service' => $description, + 'meta' => [ + 'carrier' => 'USPS', + 'mail_class' => $mailClass, + 'carrier_amount' => $carrierAmount, + 'markup_amount' => $markup, + 'markup_type' => $markupType, + ], + ]; + } + + return $rows; + } + + /** + * Build the POST /labels/v3/label request body. USPS v3 accepts + * exactly one parcel per label, so this takes a single parcel + * array (not a list). Image type is always PDF — USPS does NOT + * issue ZPL labels. + */ + public static function buildLabelRequest( + string $shipperName, + array $shipFrom, + string $recipientName, + array $shipTo, + array $parcel, + string $mailClass, + ?string $orderPublicId = null + ): array { + $packageDescription = [ + 'mailClass' => $mailClass, + 'weight' => $parcel['weight'], + 'length' => $parcel['length'], + 'width' => $parcel['width'], + 'height' => $parcel['height'], + ]; + + if ($orderPublicId !== null && $orderPublicId !== '') { + $packageDescription['customerReference'] = $orderPublicId; + } + + return [ + 'fromAddress' => array_merge($shipFrom, ['firstName' => $shipperName]), + 'toAddress' => array_merge($shipTo, ['firstName' => $recipientName]), + 'packageDescription' => $packageDescription, + 'imageInfo' => [ + 'imageType' => 'PDF', + ], + ]; + } + + /** + * Normalize the POST /labels/v3/label response into a row ready + * for persisting to Storage + the File table. USPS v3 is PDF-only + * — label_format / label_mime are forced to PDF regardless of + * what the response reports in labelMetadata. + * + * Throws RuntimeException when required fields are missing. + */ + public static function normalizeLabelResponse(array $response): array + { + $trackingNumber = $response['trackingNumber'] ?? null; + if (!is_string($trackingNumber) || $trackingNumber === '') { + throw new RuntimeException('USPS label response is missing trackingNumber'); + } + + if (!isset($response['labelImage'])) { + throw new RuntimeException('USPS label response is missing labelImage'); + } + + return [ + 'tracking_number' => $trackingNumber, + 'label_binary' => base64_decode((string) $response['labelImage']), + 'label_format' => 'PDF', + 'label_mime' => 'application/pdf', + ]; + } + + /** + * Normalize a USPS /tracking/v3/tracking/{id} response into the + * Fleetbase shape. Maps USPS v3 eventType values to Fleetbase + * codes — ALERT becomes EXCEPTION; every other known USPS code + * matches the Fleetbase code verbatim. + * + * Status is derived from the final event in the trackingEvents + * array (USPS returns events in chronological order). + */ + public static function normalizeTrackingResponse(array $response): array + { + $events = $response['trackingEvents'] ?? null; + if (!is_array($events) || empty($events)) { + return [ + 'status' => 'UNKNOWN', + 'carrier' => 'USPS', + 'events' => [], + ]; + } + + $normalized = []; + foreach ($events as $event) { + $rawType = (string) ($event['eventType'] ?? ''); + $code = self::uspsEventTypeToFleetbaseCode($rawType); + + $normalized[] = [ + 'code' => $code, + 'status' => $code, + 'timestamp' => (string) ($event['eventTimestamp'] ?? ''), + 'location' => isset($event['eventCity']) ? (string) $event['eventCity'] : null, + 'details' => null, + ]; + } + + $finalCode = end($normalized)['code'] ?? 'UNKNOWN'; + + return [ + 'status' => $finalCode, + 'carrier' => 'USPS', + 'events' => $normalized, + ]; + } + + /** + * Map a USPS v3 eventType to a Fleetbase TrackingStatus code. Most + * USPS codes map verbatim; only ALERT becomes EXCEPTION per the + * plan spec. Unknown codes pass through uppercased — the caller + * can decide how to treat them. + */ + public static function uspsEventTypeToFleetbaseCode(string $eventType): string + { + $upper = strtoupper($eventType); + if ($upper === 'ALERT') { + return 'EXCEPTION'; + } + return $upper; + } + + /** + * Normalize a USPS label refund response. USPS v3 treats label + * voids as refunds — the response carries a refundStatus field + * that can be APPROVED / PENDING / DENIED. We treat APPROVED as + * success and everything else as failure (case-insensitive). + */ + public static function normalizeVoidResponse(array $response): bool + { + $status = $response['refundStatus'] ?? null; + if (!is_string($status)) { + return false; + } + return strcasecmp($status, 'APPROVED') === 0; + } + + // ───────────────────────────────────────────────────────────────────── + // IMPURE RUNTIME WRAPPERS — compose pure helpers + HTTP + Eloquent. + // Not unit-tested in this phase; exercised via smoke test once the + // registry entry lands (Task 17 USPS half) and a real USPS Web + // Tools v3 TEM account is configured. + // ───────────────────────────────────────────────────────────────────── + + public function getQuoteFromPayload( + Payload $payload, + ?string $serviceType = null, + ?string $scheduledAt = null, + ?bool $isRouteOptimized = null + ): array { + $mailClass = null; + if ($serviceType !== null && $serviceType !== '') { + $resolved = USPSServiceType::find($serviceType); + $mailClass = $resolved?->mail_class; + } + + $shipFrom = static::placeToUspsAddress($payload->pickup); + $shipTo = static::placeToUspsAddress($payload->dropoff); + + // USPS v3 rates one parcel per request. For multi-parcel + // orders we rate the first parcel only; batch shipping is + // Phase 3 work (Task 25) and will dispatch per-parcel rates. + $firstParcel = null; + foreach ($payload->entities ?? [] as $entity) { + if (($entity->type ?? 'parcel') !== 'parcel') { + continue; + } + $firstParcel = static::entityToUspsParcel($entity); + break; + } + + if ($firstParcel === null) { + return []; + } + + $body = static::buildRatesRequest($shipFrom, $shipTo, $firstParcel, $mailClass); + $response = $this->post('/prices/v3/base-rates/search', ['json' => $body]) ?? []; + + $markupType = (string) ($this->integratedVendor?->options['markup_type'] ?? 'flat'); + $markupValue = (int) ($this->integratedVendor?->options['markup_amount'] ?? 0); + + $rows = static::normalizeRatesResponse($response, $markupType, $markupValue); + + $quotes = []; + foreach ($rows as $row) { + $serviceQuote = ServiceQuote::create([ + 'company_uuid' => $this->integratedVendor?->company_uuid, + 'payload_uuid' => $payload->uuid, + 'service_type' => 'parcel', + 'amount' => $row['amount'], + 'currency' => $row['currency'], + 'meta' => $row['meta'], + ]); + + ServiceQuoteItem::create([ + 'service_quote_uuid' => $serviceQuote->uuid, + 'amount' => $row['amount'], + 'currency' => $row['currency'], + 'details' => $row['service'], + 'code' => $row['meta']['mail_class'], + ]); + + $quotes[] = $serviceQuote; + } + + return $quotes; + } + + public function createOrderFromServiceQuote(ServiceQuote $serviceQuote, Order $order): array + { + $mailClass = (string) ($serviceQuote->meta['mail_class'] ?? ''); + if ($mailClass === '') { + throw new RuntimeException('ServiceQuote missing USPS mail_class in meta'); + } + + $shipperName = (string) ($order->payload->pickup->name ?? 'FleetOps Shipper'); + $recipientName = (string) ($order->payload->dropoff->name ?? 'Recipient'); + $shipFrom = static::placeToUspsAddress($order->payload->pickup); + $shipTo = static::placeToUspsAddress($order->payload->dropoff); + + $parcel = null; + foreach ($order->payload->entities ?? [] as $entity) { + if (($entity->type ?? 'parcel') !== 'parcel') { + continue; + } + $parcel = static::entityToUspsParcel($entity); + break; + } + if ($parcel === null) { + throw new RuntimeException('USPS label purchase requires at least one parcel entity'); + } + + $body = static::buildLabelRequest( + $shipperName, + $shipFrom, + $recipientName, + $shipTo, + $parcel, + $mailClass, + $order->public_id, + ); + + $response = $this->post('/labels/v3/label', ['json' => $body]) ?? []; + $result = static::normalizeLabelResponse($response); + + $disk = config('filesystems.default'); + $filename = 'usps_label_' . $result['tracking_number'] . '.pdf'; + $path = 'carrier-labels/' . $filename; + Storage::disk($disk)->put($path, $result['label_binary']); + + File::create([ + 'company_uuid' => $order->company_uuid, + 'subject_uuid' => $order->uuid, + 'subject_type' => Order::class, + 'content_type' => $result['label_mime'], + 'folder' => 'carrier-labels', + 'path' => $path, + 'disk' => $disk, + 'original_filename' => $filename, + ]); + + $order->updateMeta('integrated_vendor_order', [ + 'carrier' => 'USPS', + 'tracking_number' => $result['tracking_number'], + 'mail_class' => $mailClass, + ]); + + return $result; + } + + public function getTrackingStatus(string $trackingNumber): array + { + $response = $this->get('/tracking/v3/tracking/' . rawurlencode($trackingNumber)) ?? []; + return static::normalizeTrackingResponse($response); + } + + /** + * Void a USPS label by requesting a refund. USPS v3 refunds are + * allowed only for unused labels within the carrier-side refund + * window; this method does not second-guess that — it posts the + * refund request and reports the resulting refundStatus. + */ + public function voidShipment(string $trackingNumber): bool + { + $response = $this->post('/labels/v3/refund', [ + 'json' => ['trackingNumber' => $trackingNumber], + ]) ?? []; + + return static::normalizeVoidResponse($response); + } +} diff --git a/server/src/Integrations/USPS/USPSServiceType.php b/server/src/Integrations/USPS/USPSServiceType.php new file mode 100644 index 00000000..eba46bb4 --- /dev/null +++ b/server/src/Integrations/USPS/USPSServiceType.php @@ -0,0 +1,75 @@ + 'PRIORITY', 'description' => 'USPS Priority Mail', 'mail_class' => 'PRIORITY_MAIL', 'carrier' => 'USPS'], + ['key' => 'PRIORITY_EXPRESS', 'description' => 'USPS Priority Mail Express', 'mail_class' => 'PRIORITY_MAIL_EXPRESS', 'carrier' => 'USPS'], + ['key' => 'GROUND_ADVANTAGE', 'description' => 'USPS Ground Advantage', 'mail_class' => 'USPS_GROUND_ADVANTAGE', 'carrier' => 'USPS'], + ['key' => 'FIRST_CLASS', 'description' => 'USPS First Class Package Service', 'mail_class' => 'FIRST-CLASS_PACKAGE_SERVICE', 'carrier' => 'USPS'], + ['key' => 'MEDIA_MAIL', 'description' => 'USPS Media Mail', 'mail_class' => 'MEDIA_MAIL', 'carrier' => 'USPS'], + ]; + + public function __construct(array $details) + { + foreach ($details as $key => $value) { + $this->{$key} = $value; + } + } + + public function __get(string $key) + { + if (isset($this->{$key})) { + return $this->{$key}; + } + + return null; + } + + public function __call(string $key, $arguments) + { + if ($key === 'all') { + return collect(static::$serviceTypes)->mapInto(USPSServiceType::class); + } + + if (method_exists($this, $key)) { + $this->{$key}(...$arguments); + } + + return null; + } + + public function getKey() + { + return $this->key; + } + + public static function all() + { + return collect(static::$serviceTypes)->mapInto(USPSServiceType::class); + } + + public static function find($key) + { + if (is_callable($key)) { + return static::all()->first($key); + } + + if (is_string($key)) { + return static::all()->first(function ($detail) use ($key) { + return isset($detail->key) && strcasecmp($detail->key, $key) === 0; + }); + } + + return null; + } +} diff --git a/server/tests/Integrations/USPS/USPSLabelBuilderTest.php b/server/tests/Integrations/USPS/USPSLabelBuilderTest.php new file mode 100644 index 00000000..53e1449c --- /dev/null +++ b/server/tests/Integrations/USPS/USPSLabelBuilderTest.php @@ -0,0 +1,176 @@ + '1 Warehouse Way', 'city' => 'Oakland', 'province' => 'CA', 'postal_code' => '94607', + ]); + $to = USPS::placeToUspsAddress((object) [ + 'street1' => '350 5th Ave', 'city' => 'New York', 'province' => 'NY', 'postal_code' => '10118', + ]); + $parcel = USPS::entityToUspsParcel((object) ['length' => 12, 'width' => 9, 'height' => 3, 'weight' => 2.5]); + + $body = USPS::buildLabelRequest('ACME Shipper', $from, 'Acme Customer', $to, $parcel, 'PRIORITY_MAIL', 'order_abc'); + + expect($body['fromAddress']['streetAddress'])->toBe('1 Warehouse Way'); + expect($body['fromAddress']['ZIPCode'])->toBe('94607'); + expect($body['toAddress']['streetAddress'])->toBe('350 5th Ave'); + expect($body['toAddress']['ZIPCode'])->toBe('10118'); + expect($body['packageDescription']['mailClass'])->toBe('PRIORITY_MAIL'); + expect($body['packageDescription']['weight'])->toBe(2.5); + expect($body['packageDescription']['length'])->toBe(12.0); + expect($body['packageDescription']['width'])->toBe(9.0); + expect($body['packageDescription']['height'])->toBe(3.0); +}); + +test('buildLabelRequest attaches shipper and recipient names', function () { + $from = USPS::placeToUspsAddress((object) ['postal_code' => '94607']); + $to = USPS::placeToUspsAddress((object) ['postal_code' => '10118']); + $parcel = USPS::entityToUspsParcel((object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1]); + + $body = USPS::buildLabelRequest('ACME', $from, 'Customer A', $to, $parcel, 'PRIORITY_MAIL'); + expect($body['fromAddress']['firstName'])->toBe('ACME'); + expect($body['toAddress']['firstName'])->toBe('Customer A'); +}); + +test('buildLabelRequest attaches a customer reference when an order public_id is provided', function () { + $from = USPS::placeToUspsAddress((object) ['postal_code' => '94607']); + $to = USPS::placeToUspsAddress((object) ['postal_code' => '10118']); + $parcel = USPS::entityToUspsParcel((object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1]); + + $body = USPS::buildLabelRequest('A', $from, 'B', $to, $parcel, 'PRIORITY_MAIL', 'order_xyz'); + expect($body['packageDescription']['customerReference'])->toBe('order_xyz'); +}); + +test('buildLabelRequest always sets imageType to PDF (no ZPL path)', function () { + $from = USPS::placeToUspsAddress((object) ['postal_code' => '94607']); + $to = USPS::placeToUspsAddress((object) ['postal_code' => '10118']); + $parcel = USPS::entityToUspsParcel((object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1]); + + $body = USPS::buildLabelRequest('A', $from, 'B', $to, $parcel, 'PRIORITY_MAIL'); + expect($body['imageInfo']['imageType'])->toBe('PDF'); +}); + +// ── normalizeLabelResponse ─────────────────────────────────────────────── + +test('normalizeLabelResponse extracts trackingNumber and decodes labelImage', function () { + $bytes = '%PDF-1.4 fake usps label'; + $resp = [ + 'trackingNumber' => '9400111202555999999999', + 'labelImage' => base64_encode($bytes), + 'labelMetadata' => ['labelImageFormat' => 'PDF'], + ]; + + $row = USPS::normalizeLabelResponse($resp); + + expect($row['tracking_number'])->toBe('9400111202555999999999'); + expect($row['label_binary'])->toBe($bytes); + expect($row['label_format'])->toBe('PDF'); + expect($row['label_mime'])->toBe('application/pdf'); +}); + +test('normalizeLabelResponse defaults labelImageFormat to PDF when absent', function () { + $resp = [ + 'trackingNumber' => '9400', + 'labelImage' => base64_encode('bin'), + ]; + $row = USPS::normalizeLabelResponse($resp); + expect($row['label_format'])->toBe('PDF'); + expect($row['label_mime'])->toBe('application/pdf'); +}); + +test('normalizeLabelResponse forces PDF even if response reports a non-PDF format', function () { + // USPS v3 does not issue ZPL labels; enforce PDF-only + // semantics regardless of what the response carries. + $resp = [ + 'trackingNumber' => '9400', + 'labelImage' => base64_encode('bin'), + 'labelMetadata' => ['labelImageFormat' => 'TIFF'], + ]; + $row = USPS::normalizeLabelResponse($resp); + expect($row['label_format'])->toBe('PDF'); + expect($row['label_mime'])->toBe('application/pdf'); +}); + +test('normalizeLabelResponse throws when trackingNumber is missing', function () { + expect(fn () => USPS::normalizeLabelResponse(['labelImage' => base64_encode('x')])) + ->toThrow(RuntimeException::class); +}); + +test('normalizeLabelResponse throws when labelImage is missing', function () { + expect(fn () => USPS::normalizeLabelResponse(['trackingNumber' => '9400'])) + ->toThrow(RuntimeException::class); +}); + +// ── normalizeTrackingResponse ──────────────────────────────────────────── + +test('normalizeTrackingResponse returns status + carrier + events', function () { + $resp = [ + 'trackingEvents' => [ + ['eventType' => 'ACCEPTED', 'eventTimestamp' => '2026-04-07T10:00:00Z', 'eventCity' => 'Oakland'], + ['eventType' => 'IN_TRANSIT', 'eventTimestamp' => '2026-04-08T08:00:00Z', 'eventCity' => 'Reno'], + ['eventType' => 'DELIVERED', 'eventTimestamp' => '2026-04-09T14:22:00Z', 'eventCity' => 'New York'], + ], + ]; + $result = USPS::normalizeTrackingResponse($resp); + expect($result['status'])->toBe('DELIVERED'); // last event's code + expect($result['carrier'])->toBe('USPS'); + expect($result['events'])->toHaveCount(3); + expect($result['events'][0]['code'])->toBe('ACCEPTED'); + expect($result['events'][2]['code'])->toBe('DELIVERED'); +}); + +test('normalizeTrackingResponse maps ALERT event type to EXCEPTION', function () { + $resp = ['trackingEvents' => [['eventType' => 'ALERT', 'eventTimestamp' => '2026-04-07T10:00:00Z']]]; + $result = USPS::normalizeTrackingResponse($resp); + expect($result['events'][0]['code'])->toBe('EXCEPTION'); + expect($result['status'])->toBe('EXCEPTION'); +}); + +test('normalizeTrackingResponse preserves known codes verbatim (DELIVERED, IN_TRANSIT, etc)', function () { + $resp = ['trackingEvents' => [ + ['eventType' => 'ACCEPTED', 'eventTimestamp' => 't'], + ['eventType' => 'IN_TRANSIT', 'eventTimestamp' => 't'], + ['eventType' => 'OUT_FOR_DELIVERY', 'eventTimestamp' => 't'], + ['eventType' => 'ARRIVAL_AT_POST_OFFICE', 'eventTimestamp' => 't'], + ['eventType' => 'RETURN_TO_SENDER', 'eventTimestamp' => 't'], + ]]; + $result = USPS::normalizeTrackingResponse($resp); + $codes = array_column($result['events'], 'code'); + expect($codes)->toBe(['ACCEPTED', 'IN_TRANSIT', 'OUT_FOR_DELIVERY', 'ARRIVAL_AT_POST_OFFICE', 'RETURN_TO_SENDER']); +}); + +test('normalizeTrackingResponse uppercases lowercase event codes', function () { + $resp = ['trackingEvents' => [['eventType' => 'delivered', 'eventTimestamp' => 't']]]; + $result = USPS::normalizeTrackingResponse($resp); + expect($result['events'][0]['code'])->toBe('DELIVERED'); +}); + +test('normalizeTrackingResponse returns UNKNOWN status + empty events when events missing', function () { + expect(USPS::normalizeTrackingResponse([]))->toBe([ + 'status' => 'UNKNOWN', + 'carrier' => 'USPS', + 'events' => [], + ]); +}); + +// ── normalizeVoidResponse ──────────────────────────────────────────────── + +test('normalizeVoidResponse returns true when refundStatus is APPROVED', function () { + expect(USPS::normalizeVoidResponse(['refundStatus' => 'APPROVED']))->toBeTrue(); +}); + +test('normalizeVoidResponse is case-insensitive on status', function () { + expect(USPS::normalizeVoidResponse(['refundStatus' => 'approved']))->toBeTrue(); +}); + +test('normalizeVoidResponse returns false on PENDING', function () { + expect(USPS::normalizeVoidResponse(['refundStatus' => 'PENDING']))->toBeFalse(); +}); + +test('normalizeVoidResponse returns false on empty response', function () { + expect(USPS::normalizeVoidResponse([]))->toBeFalse(); +}); diff --git a/server/tests/Integrations/USPS/USPSRatesBuilderTest.php b/server/tests/Integrations/USPS/USPSRatesBuilderTest.php new file mode 100644 index 00000000..60110860 --- /dev/null +++ b/server/tests/Integrations/USPS/USPSRatesBuilderTest.php @@ -0,0 +1,266 @@ + new Response( + $payload['status'] ?? 200, + ['Content-Type' => 'application/json'], + json_encode($payload['body'] ?? []) + ), + $responses + )); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($this->history)); + + $this->cache = new \ArrayObject(); + $this->bridge = new USPS('test-client', 'test-secret', $sandbox, $stack, $this->cache); + } +} + +// ── placeToUspsAddress ─────────────────────────────────────────────────── + +test('placeToUspsAddress maps street/city/state/zip', function () { + $place = (object) [ + 'street1' => '1 Main St', + 'city' => 'Boise', + 'province' => 'ID', + 'postal_code' => '83702', + 'country' => 'US', + ]; + $addr = USPS::placeToUspsAddress($place); + expect($addr['streetAddress'])->toBe('1 Main St'); + expect($addr['city'])->toBe('Boise'); + expect($addr['state'])->toBe('ID'); + expect($addr['ZIPCode'])->toBe('83702'); +}); + +test('placeToUspsAddress coerces nulls to empty strings', function () { + $place = (object) ['street1' => null, 'city' => null, 'province' => null, 'postal_code' => null]; + $addr = USPS::placeToUspsAddress($place); + expect($addr['streetAddress'])->toBe(''); + expect($addr['city'])->toBe(''); + expect($addr['ZIPCode'])->toBe(''); +}); + +// ── entityToUspsParcel ─────────────────────────────────────────────────── + +test('entityToUspsParcel builds parcel shape with length/width/height/weight', function () { + $entity = (object) [ + 'type' => 'parcel', + 'length' => 12.0, + 'width' => 9.0, + 'height' => 3.0, + 'weight' => 2.5, + ]; + $parcel = USPS::entityToUspsParcel($entity); + expect($parcel['length'])->toBe(12.0); + expect($parcel['width'])->toBe(9.0); + expect($parcel['height'])->toBe(3.0); + // USPS weight is in pounds for v3 + expect($parcel['weight'])->toBe(2.5); +}); + +// ── buildRatesRequest ──────────────────────────────────────────────────── + +test('buildRatesRequest assembles origin/destination ZIP + parcel', function () { + $body = USPS::buildRatesRequest( + USPS::placeToUspsAddress((object) ['postal_code' => '94110']), + USPS::placeToUspsAddress((object) ['postal_code' => '10001']), + USPS::entityToUspsParcel((object) ['length' => 10, 'width' => 10, 'height' => 10, 'weight' => 3]) + ); + + expect($body['originZIPCode'])->toBe('94110'); + expect($body['destinationZIPCode'])->toBe('10001'); + expect($body['weight'])->toBe(3.0); + expect($body['length'])->toBe(10.0); + expect($body['width'])->toBe(10.0); + expect($body['height'])->toBe(10.0); +}); + +test('buildRatesRequest includes mailClass when provided', function () { + $body = USPS::buildRatesRequest( + USPS::placeToUspsAddress((object) ['postal_code' => '94110']), + USPS::placeToUspsAddress((object) ['postal_code' => '10001']), + USPS::entityToUspsParcel((object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1]), + 'PRIORITY_MAIL' + ); + expect($body['mailClass'])->toBe('PRIORITY_MAIL'); +}); + +test('buildRatesRequest omits mailClass when null for search-all behavior', function () { + $body = USPS::buildRatesRequest( + USPS::placeToUspsAddress((object) ['postal_code' => '94110']), + USPS::placeToUspsAddress((object) ['postal_code' => '10001']), + USPS::entityToUspsParcel((object) ['length' => 1, 'width' => 1, 'height' => 1, 'weight' => 1]) + ); + expect(isset($body['mailClass']))->toBeFalse(); +}); + +// ── normalizeRatesResponse ─────────────────────────────────────────────── + +test('normalizeRatesResponse converts dollars to integer cents', function () { + $rows = USPS::normalizeRatesResponse( + ['rates' => [ + ['mailClass' => 'PRIORITY_MAIL', 'price' => 8.10], + ['mailClass' => 'USPS_GROUND_ADVANTAGE', 'price' => 5.20], + ]], + 'flat', + 0 + ); + + expect($rows)->toHaveCount(2); + expect($rows[0]['amount'])->toBe(810); + expect($rows[0]['meta']['carrier'])->toBe('USPS'); + expect($rows[0]['meta']['mail_class'])->toBe('PRIORITY_MAIL'); + expect($rows[0]['meta']['carrier_amount'])->toBe(810); + expect($rows[1]['amount'])->toBe(520); +}); + +test('normalizeRatesResponse applies flat markup in cents', function () { + $rows = USPS::normalizeRatesResponse( + ['rates' => [['mailClass' => 'PRIORITY_MAIL', 'price' => 8.10]]], + 'flat', + 25 + ); + // 810 carrier + 25 flat = 835 sell + expect($rows[0]['amount'])->toBe(835); + expect($rows[0]['meta']['carrier_amount'])->toBe(810); + expect($rows[0]['meta']['markup_amount'])->toBe(25); + expect($rows[0]['meta']['markup_type'])->toBe('flat'); +}); + +test('normalizeRatesResponse applies percent markup', function () { + $rows = USPS::normalizeRatesResponse( + ['rates' => [['mailClass' => 'PRIORITY_MAIL', 'price' => 10.00]]], + 'percent', + 10 + ); + // 1000 + 10% = 1100 + expect($rows[0]['amount'])->toBe(1100); + expect($rows[0]['meta']['markup_amount'])->toBe(100); +}); + +test('normalizeRatesResponse resolves description via USPSServiceType', function () { + $rows = USPS::normalizeRatesResponse( + ['rates' => [['mailClass' => 'PRIORITY_MAIL', 'price' => 5.00]]], + 'flat', + 0 + ); + expect($rows[0]['service'])->toBe('USPS Priority Mail'); +}); + +test('normalizeRatesResponse skips rows missing a price', function () { + $rows = USPS::normalizeRatesResponse( + ['rates' => [ + ['mailClass' => 'PRIORITY_MAIL', 'price' => 5.00], + ['mailClass' => 'FLAT_RATE_ENVELOPE', 'error' => 'not_available'], + ['mailClass' => 'USPS_GROUND_ADVANTAGE', 'price' => 4.00], + ]], + 'flat', + 0 + ); + expect($rows)->toHaveCount(2); +}); + +test('normalizeRatesResponse returns empty array when rates key missing', function () { + expect(USPS::normalizeRatesResponse([], 'flat', 0))->toBe([]); +}); + +test('normalizeRatesResponse handles sub-cent rounding', function () { + $rows = USPS::normalizeRatesResponse( + ['rates' => [ + ['mailClass' => 'PRIORITY_MAIL', 'price' => 8.425], + ['mailClass' => 'USPS_GROUND_ADVANTAGE', 'price' => 8.424], + ]], + 'flat', + 0 + ); + expect($rows[0]['amount'])->toBe(843); + expect($rows[1]['amount'])->toBe(842); +}); + +// ── Inline OAuth ───────────────────────────────────────────────────────── + +test('sandbox host routes to apis-tem.usps.com', function () { + $h = new USPSTestHarness([ + ['status' => 200, 'body' => ['access_token' => 'tok', 'expires_in' => 3600]], + ], true); + $h->bridge->getAccessToken(); + + expect((string) $h->history[0]['request']->getUri()) + ->toStartWith('https://apis-tem.usps.com/oauth2/v3/token'); +}); + +test('production host routes to api.usps.com', function () { + $h = new USPSTestHarness([ + ['status' => 200, 'body' => ['access_token' => 'tok', 'expires_in' => 3600]], + ], false); + $h->bridge->getAccessToken(); + + expect((string) $h->history[0]['request']->getUri()) + ->toStartWith('https://api.usps.com/oauth2/v3/token'); +}); + +test('oauth token is cached under usps_oauth_token_{clientId}', function () { + $h = new USPSTestHarness([ + ['status' => 200, 'body' => ['access_token' => 'usps-tok', 'expires_in' => 3600]], + ]); + + $token = $h->bridge->getAccessToken(); + + expect($token)->toBe('usps-tok'); + expect($h->cache['usps_oauth_token_test-client'])->toBe('usps-tok'); +}); + +test('second getAccessToken call within TTL returns cached value without HTTP', function () { + $h = new USPSTestHarness([ + ['status' => 200, 'body' => ['access_token' => 'first', 'expires_in' => 3600]], + ]); + + $first = $h->bridge->getAccessToken(); + $second = $h->bridge->getAccessToken(); + + expect($first)->toBe('first'); + expect($second)->toBe('first'); + expect($h->history)->toHaveCount(1); +}); + +test('oauth request body is grant_type=client_credentials with basic auth', function () { + $h = new USPSTestHarness([ + ['status' => 200, 'body' => ['access_token' => 'tok', 'expires_in' => 3600]], + ]); + $h->bridge->getAccessToken(); + + $req = $h->history[0]['request']; + expect($req->getMethod())->toBe('POST'); + expect($req->getHeaderLine('Authorization'))->toBe('Basic ' . base64_encode('test-client:test-secret')); + expect((string) $req->getBody())->toBe('grant_type=client_credentials'); +}); + +test('non-2xx oauth response throws RuntimeException', function () { + $h = new USPSTestHarness([ + ['status' => 401, 'body' => ['error' => 'invalid_client']], + ]); + expect(fn () => $h->bridge->getAccessToken())->toThrow(RuntimeException::class); +}); + +test('missing access_token in oauth response throws RuntimeException', function () { + $h = new USPSTestHarness([ + ['status' => 200, 'body' => ['expires_in' => 3600]], + ]); + expect(fn () => $h->bridge->getAccessToken())->toThrow(RuntimeException::class); +}); diff --git a/server/tests/Integrations/USPS/USPSServiceTypeTest.php b/server/tests/Integrations/USPS/USPSServiceTypeTest.php new file mode 100644 index 00000000..e8448ac4 --- /dev/null +++ b/server/tests/Integrations/USPS/USPSServiceTypeTest.php @@ -0,0 +1,57 @@ +toHaveCount(5); +}); + +test('every USPS service is discoverable by key', function () { + $expected = ['PRIORITY', 'PRIORITY_EXPRESS', 'GROUND_ADVANTAGE', 'FIRST_CLASS', 'MEDIA_MAIL']; + foreach ($expected as $key) { + expect(USPSServiceType::find($key))->not->toBeNull("service $key should exist"); + } +}); + +test('PRIORITY maps to USPS mail class PRIORITY_MAIL', function () { + $type = USPSServiceType::find('PRIORITY'); + expect($type->description)->toBe('USPS Priority Mail'); + expect($type->mail_class)->toBe('PRIORITY_MAIL'); +}); + +test('PRIORITY_EXPRESS maps to USPS mail class PRIORITY_MAIL_EXPRESS', function () { + expect(USPSServiceType::find('PRIORITY_EXPRESS')->mail_class)->toBe('PRIORITY_MAIL_EXPRESS'); +}); + +test('GROUND_ADVANTAGE maps to USPS mail class USPS_GROUND_ADVANTAGE', function () { + expect(USPSServiceType::find('GROUND_ADVANTAGE')->mail_class)->toBe('USPS_GROUND_ADVANTAGE'); +}); + +test('FIRST_CLASS maps to USPS mail class FIRST-CLASS_PACKAGE_SERVICE', function () { + expect(USPSServiceType::find('FIRST_CLASS')->mail_class)->toBe('FIRST-CLASS_PACKAGE_SERVICE'); +}); + +test('MEDIA_MAIL maps to USPS mail class MEDIA_MAIL', function () { + expect(USPSServiceType::find('MEDIA_MAIL')->mail_class)->toBe('MEDIA_MAIL'); +}); + +test('find() is case-insensitive', function () { + expect(USPSServiceType::find('priority'))->not->toBeNull(); + expect(USPSServiceType::find('Priority')->description)->toBe('USPS Priority Mail'); +}); + +test('find() returns null for unknown key', function () { + expect(USPSServiceType::find('FOREVER_STAMPS'))->toBeNull(); +}); + +test('find() accepts a callable', function () { + $type = USPSServiceType::find(fn ($t) => $t->mail_class === 'MEDIA_MAIL'); + expect($type)->not->toBeNull(); + expect($type->key)->toBe('MEDIA_MAIL'); +}); + +test('every USPS service has carrier USPS', function () { + foreach (USPSServiceType::all() as $type) { + expect($type->carrier)->toBe('USPS'); + } +}); From c7a535705b20a5a0030c31ea2d09b8538f198f1b Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 15:22:54 -0700 Subject: [PATCH 22/25] feat(usps): register in IntegratedVendors (completes Task 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends the usps entry to IntegratedVendors::\$supported, completing the Task 17 registry wiring started when the UPS half landed in 8c7d495b. USPS bridge class (committed in the Task 16 change on this branch) is now constructible through IntegratedVendor::api(). ## Registry shape (mirrors UPS with two deliberate differences) - code: 'usps' - host: https://api.usps.com/ - sandbox: https://apis-tem.usps.com/ - namespace: 'v3' - bridge: USPS::class - svc_bridge: USPSServiceType::class - iso2cc_bridge: null - credentialParams: client_id, client_secret (NO account_number) - optionParams: markup_type, markup_amount, client_label (NO label_format — USPS v3 is PDF-only; enforced in USPS::normalizeLabelResponse regardless of what the carrier response reports) - bridgeParams: credentials.client_id -> clientId, credentials.client_secret -> clientSecret, sandbox -> sandbox (NO accountNumber constructor arg) - callbacks: [] (no webhook registration) ## Differences from the UPS entry, called out explicitly - No account_number credential field. USPS v3 rates and labels are zip-code and credential scoped — there is no shipper account number. UPS's constructor takes three credential fields; USPS's takes two. - No label_format option param. UPS supports PDF and ZPL (opt-in for thermal printer users); USPS v3 does NOT issue ZPL labels at all, so a label_format option would be misleading dead configuration surface. Everything else is identical to the UPS registry entry (markup options, bridgeParams mapping, empty callbacks list). ## Logo asset tracking Adds server/src/Integrations/README-logos.md documenting the expected integrated vendor logo asset paths and their sourcing requirements. The actual usps.png (and ups.png, and parcelpath.png from Phase 1) are still pending — UPS and USPS have strict brand usage guidelines that require sourcing from official carrier brand asset pages and legal review before merge. Until the PNGs are added the IV form will render broken-image icons for these three providers; the registry logic is not blocked. Documented so reviewers catch it during the Phase 2 PR. No UI changes. No controller changes. No runtime smoke test. Nothing in this commit triggers a USPS API call — the bridge is merely reachable via the dependency-injection chain now, same as the UPS half in 8c7d495b. Tests (Pest): 12 new USPS registry assertions covering core shape, credential param restrictions (no account_number), option param restrictions (no label_format), bridge param mapping, empty callbacks, and regression guards on parcelpath/ups/lalamove still being present. Full suite: 224 passed (509 assertions). Task 17 is now complete: both UPS and USPS are registered. --- server/src/Integrations/README-logos.md | 34 ++++++ server/src/Support/IntegratedVendors.php | 35 ++++++ .../IntegratedVendorsUspsRegistryTest.php | 108 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 server/src/Integrations/README-logos.md create mode 100644 server/tests/Support/IntegratedVendorsUspsRegistryTest.php diff --git a/server/src/Integrations/README-logos.md b/server/src/Integrations/README-logos.md new file mode 100644 index 00000000..15cbc130 --- /dev/null +++ b/server/src/Integrations/README-logos.md @@ -0,0 +1,34 @@ +# Integrated Vendor Logo Assets + +`ResolvedIntegratedVendor::logo()` resolves logos via +`Utils::assetFromFleetbase('integrated-vendors/' . $code . '.png')`, +which serves them out of the Ember host app's `public/images/integrated-vendors/` +directory. Filenames must be lowercase and match the registry `code` field exactly. + +## Expected assets for Phase 1 + Phase 2 + +| Code | Expected path (in ember host) | Status | +|---|---|---| +| `lalamove` | `public/images/integrated-vendors/lalamove.png` | (existing — not added by this PR chain) | +| `parcelpath` | `public/images/integrated-vendors/parcelpath.png` | **pending** — referenced by Phase 1 Task 6 registry entry and by Phase 1 Task 10 onboarding panel and Task 11 rate comparison UI | +| `ups` | `public/images/integrated-vendors/ups.png` | **pending** — referenced by Phase 2 Task 17 UPS registry entry and by Phase 1 Task 10 onboarding panel | +| `usps` | `public/images/integrated-vendors/usps.png` | **pending** — referenced by Phase 2 Task 17 USPS registry entry and by Phase 1 Task 10 onboarding panel | + +Until the actual PNG files are added, the IV form and rate comparison +UI will render broken-image icons for these providers. The logic is +not blocked — every `ResolvedIntegratedVendor` still resolves a logo +URL; the URL just happens to 404 until the assets land. + +## Sourcing + +UPS and USPS have strict brand usage guidelines. Any logo added must +be sourced from the carrier's official brand assets page and reviewed +against their usage terms before merging: + +- UPS: https://about.ups.com/us/en/our-company/our-brand.html +- USPS: https://about.usps.com/newsroom/photography/ + +ParcelPath is a third-party rate broker and should use the logo +provided by their branding team. + +This file is documentation only — it has no runtime effect. diff --git a/server/src/Support/IntegratedVendors.php b/server/src/Support/IntegratedVendors.php index 9da93fdd..6995803d 100644 --- a/server/src/Support/IntegratedVendors.php +++ b/server/src/Support/IntegratedVendors.php @@ -9,6 +9,8 @@ use Fleetbase\FleetOps\Integrations\ParcelPath\ParcelPathServiceType; use Fleetbase\FleetOps\Integrations\UPS\UPS; use Fleetbase\FleetOps\Integrations\UPS\UPSServiceType; +use Fleetbase\FleetOps\Integrations\USPS\USPS; +use Fleetbase\FleetOps\Integrations\USPS\USPSServiceType; use Fleetbase\FleetOps\Models\IntegratedVendor; use Illuminate\Support\Str; @@ -309,6 +311,39 @@ class IntegratedVendors ], 'callbacks' => [], ], + [ + 'name' => 'USPS', + 'code' => 'usps', + 'host' => 'https://api.usps.com/', + 'sandbox' => 'https://apis-tem.usps.com/', + 'namespace' => 'v3', + 'bridge' => USPS::class, + 'svc_bridge' => USPSServiceType::class, + 'iso2cc_bridge' => null, + 'credentialParams' => [ + ['key' => 'client_id'], + ['key' => 'client_secret'], + // USPS v3 rates and labels are zip-code and credential + // scoped — there is no shipper account number, unlike UPS. + ], + 'optionParams' => [ + // NOTE: no label_format option. USPS v3 is PDF-only; + // the normalizer in USPS::normalizeLabelResponse enforces + // application/pdf regardless of what the response reports. + ['key' => 'markup_type', 'options' => [ + ['value' => 'flat', 'label' => 'Flat (cents)'], + ['value' => 'percent', 'label' => 'Percentage'], + ], 'optionValue' => 'value', 'optionLabel' => 'label'], + ['key' => 'markup_amount'], + ['key' => 'client_label'], + ], + 'bridgeParams' => [ + 'clientId' => 'credentials.client_id', + 'clientSecret' => 'credentials.client_secret', + 'sandbox' => 'sandbox', + ], + 'callbacks' => [], + ], ]; public static function all() diff --git a/server/tests/Support/IntegratedVendorsUspsRegistryTest.php b/server/tests/Support/IntegratedVendorsUspsRegistryTest.php new file mode 100644 index 00000000..cd173f07 --- /dev/null +++ b/server/tests/Support/IntegratedVendorsUspsRegistryTest.php @@ -0,0 +1,108 @@ +fail('usps entry not registered in IntegratedVendors::$supported'); +} + +// ── Core shape ─────────────────────────────────────────────────────────── + +test('usps entry is registered with the expected core fields', function () { + $entry = uspsEntry(); + + expect($entry['name'])->toBe('USPS'); + expect($entry['host'])->toBe('https://api.usps.com/'); + expect($entry['sandbox'])->toBe('https://apis-tem.usps.com/'); + expect($entry['namespace'])->toBe('v3'); + expect($entry['bridge'])->toBe(USPS::class); + expect($entry['svc_bridge'])->toBe(USPSServiceType::class); + expect($entry['iso2cc_bridge'])->toBeNull(); +}); + +// ── Credential params (NO account_number) ─────────────────────────────── + +test('usps entry declares client_id and client_secret credential params only', function () { + $entry = uspsEntry(); + $keys = array_column($entry['credentialParams'], 'key'); + expect($keys)->toBe(['client_id', 'client_secret']); +}); + +test('usps entry does NOT declare account_number (rates are zip-scoped)', function () { + $entry = uspsEntry(); + $keys = array_column($entry['credentialParams'], 'key'); + expect($keys)->not->toContain('account_number'); +}); + +// ── Option params (NO label_format — USPS is PDF-only) ───────────────── + +test('usps entry declares markup_type / markup_amount / client_label option params', function () { + $entry = uspsEntry(); + $keys = array_column($entry['optionParams'], 'key'); + + expect($keys)->toContain('markup_type'); + expect($keys)->toContain('markup_amount'); + expect($keys)->toContain('client_label'); +}); + +test('usps entry does NOT declare a label_format option (PDF-only)', function () { + $entry = uspsEntry(); + $keys = array_column($entry['optionParams'], 'key'); + expect($keys)->not->toContain('label_format'); +}); + +test('usps markup_type option exposes flat and percent', function () { + $entry = uspsEntry(); + $markup = collect($entry['optionParams'])->firstWhere('key', 'markup_type'); + $values = array_column($markup['options'], 'value'); + expect($values)->toEqualCanonicalizing(['flat', 'percent']); +}); + +// ── Bridge params ──────────────────────────────────────────────────────── + +test('usps bridgeParams map credentials and sandbox to constructor args', function () { + $entry = uspsEntry(); + expect($entry['bridgeParams']['clientId'])->toBe('credentials.client_id'); + expect($entry['bridgeParams']['clientSecret'])->toBe('credentials.client_secret'); + expect($entry['bridgeParams']['sandbox'])->toBe('sandbox'); +}); + +test('usps bridgeParams do NOT include accountNumber', function () { + $entry = uspsEntry(); + expect(isset($entry['bridgeParams']['accountNumber']))->toBeFalse(); +}); + +// ── Callbacks ──────────────────────────────────────────────────────────── + +test('usps has no callbacks (no webhook registration)', function () { + $entry = uspsEntry(); + expect($entry['callbacks'])->toBe([]); +}); + +// ── Regression guards ──────────────────────────────────────────────────── + +test('parcelpath entry is still present after USPS registration (regression guard)', function () { + $parcelpath = collect(IntegratedVendors::$supported)->firstWhere('code', 'parcelpath'); + expect($parcelpath)->not->toBeNull(); +}); + +test('ups entry is still present after USPS registration (regression guard)', function () { + $ups = collect(IntegratedVendors::$supported)->firstWhere('code', 'ups'); + expect($ups)->not->toBeNull(); +}); + +test('all four providers (lalamove, parcelpath, ups, usps) are registered', function () { + $codes = array_column(IntegratedVendors::$supported, 'code'); + expect($codes)->toContain('lalamove'); + expect($codes)->toContain('parcelpath'); + expect($codes)->toContain('ups'); + expect($codes)->toContain('usps'); +}); From e96465ad658c181067cb29fe1178704559d31a8a Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 15:44:21 -0700 Subject: [PATCH 23/25] feat(service-quotes): broker auto-resolve by shipper_client_uuid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the IntegratedVendorResolver orchestration layer and wires it into ServiceQuoteController::queryRecord so broker deployments can route quote requests through the right carrier credential automatically based on the order's customer Vendor. ## Architecture: pure + impure split IntegratedVendorResolver is a new Support class with two layers matching the Phase 1 / Phase 2 bridge convention: - chooseVendorUuids(candidates, shipperClientUuid, providerFilter) Pure static function. Takes an array of candidate rows with at least {uuid, provider, shipper_client_uuid} and returns the list of vendor UUIDs to route through. Deterministic, fully unit-testable under Pest without booting Laravel. - resolveForQuoteRequest(companyUuid, order, providerFilter) Impure wrapper. Queries IntegratedVendor via Eloquent, runs the pure chooser, hydrates back to models ordered to match the pure result. Used by the controller. ## Resolution rule (asserted by 13 new pure tests) For each distinct provider in the candidate list: 1. If shipperClientUuid is non-null AND an exact-match candidate exists, pick it. 2. Otherwise, if a catch-all candidate exists (shipper_client_uuid IS NULL), pick it. 3. Otherwise, silently drop the provider. NEVER route through a mismatched shipper_client_uuid — that would bill the wrong account, which is the entire reason this rule exists. Breaking this invariant is the single biggest risk a broker-scale deployment faces. When providerFilter is non-empty, only candidates whose provider is in the filter are considered. ## ServiceQuoteController::queryRecord changes - Keeps the existing explicit facilitator=integrated_vendor_xxx path fully intact — passing that URL param still bypasses the auto-resolve block entirely. Backward compatible. - Adds a new block after the explicit-facilitator check: when the request carries an `order` public_id and/or a `providers` filter, the controller 1. loads the Order (with customer) if order is set 2. parses providers from a comma-separated string or an array 3. calls IntegratedVendorResolver::resolveForQuoteRequest 4. calls ->api()->getQuoteFromPayload on every resolved vendor 5. aggregates all returned ServiceQuote rows into one response - Per-vendor failures are reported via report() but do NOT abort the batch — a single misconfigured carrier credential should not block rate discovery for the others. Only vendors that made it past the resolver's consistency filter ever enter the loop, so exceptions here are upstream carrier errors worth surfacing to observability rather than flipping the whole request to a 400. - Falls through to the existing ServiceRate flow when the auto-resolve block finds nothing. Zero regression on workflows that don't use IntegratedVendors. ## Request contract (new optional inputs) - `order` (string, optional) — the Order public_id this quote request is for. When present, the controller loads the order to extract the shipper client Vendor from its customer_uuid (only when customer_type is vendor). - `providers` (string or array, optional) — comma-separated or array list of provider codes (e.g. 'ups,usps') restricting the auto-resolve to those carriers. Empty = no filter. Neither input is required. Both are opt-in. Existing callers that pass neither `order` nor `providers` continue to get the original ServiceRate behavior when no explicit facilitator is set. ## Not in this commit - No UI changes. The Ember order form will need to start passing `order=public_id` on quote requests to exercise the new path; that wiring is Phase 2 Task 20 (Shipper Client selector) + a small adjacent tweak to serviceRateActions.getServiceQuotes. - No live carrier API calls. The pure chooser is fully unit-tested and the impure wrapper is exercised at runtime via smoke test once the Task 20 UI wiring lands. - No controller changes to the Api/v1 variant — Task 19's scope is the Internal/v1 ServiceQuoteController that the Ember console hits. The public Api/v1 controller can adopt the same resolver in a follow-up. ## Edge cases the resolver explicitly handles (asserted by tests) - Exact match vs fallback — covered - Client-specific match even when catch-all comes first in the candidate list (ordering is not load-bearing) — covered - Multi-provider resolution — one row per distinct provider - Mixed client-specific + catch-all within the same candidate set - Silent provider drop when no candidates exist at all - Silent provider drop when neither client-specific nor catch-all exists for the requested shipper - Empty candidate list returns empty - Null shipper client uses only whereNull candidates (non-broker / direct customer path) - Provider filter restriction - Empty provider filter treated as no filter - Null provider filter treated as no filter - Determinism across candidate ordering Tests (Pest): 13 new pure-chooser tests, 23 assertions. Full suite: 237 passed (532 assertions). No new runtime HTTP calls — the resolver operates on whatever IntegratedVendor rows the company already has configured. --- .../Internal/v1/ServiceQuoteController.php | 74 ++++++ .../src/Support/IntegratedVendorResolver.php | 176 +++++++++++++ .../Support/IntegratedVendorResolverTest.php | 237 ++++++++++++++++++ 3 files changed, 487 insertions(+) create mode 100644 server/src/Support/IntegratedVendorResolver.php create mode 100644 server/tests/Support/IntegratedVendorResolverTest.php diff --git a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php index 8c1699b6..d9b92adc 100644 --- a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php @@ -5,12 +5,14 @@ use Fleetbase\FleetOps\Http\Controllers\FleetOpsController; use Fleetbase\FleetOps\Models\Entity; use Fleetbase\FleetOps\Models\IntegratedVendor; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\PurchaseRate; use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\FleetOps\Models\ServiceQuoteItem; use Fleetbase\FleetOps\Models\ServiceRate; +use Fleetbase\FleetOps\Support\IntegratedVendorResolver; use Fleetbase\FleetOps\Support\Payment; use Fleetbase\FleetOps\Support\Utils; use Illuminate\Http\Request; @@ -82,6 +84,78 @@ public function queryRecord(Request $request) return response()->json($serviceQuotes); } + // ───────────────────────────────────────────────────────────────── + // Phase 2 Task 19: automatic IntegratedVendor resolution. + // + // When no explicit facilitator was passed, check whether the + // request is associated with an Order whose customer is a + // Vendor (shipper client). If so, resolve the set of matching + // IntegratedVendor credentials via IntegratedVendorResolver and + // fetch quotes from every one of them in a single batch. This + // is the broker auto-routing path — a dispatcher picks only a + // carrier (UPS / USPS / ParcelPath) and the system routes + // through the credential record scoped to the order's customer, + // falling back to the catch-all when no client-specific record + // exists. + // + // Compatible with the existing single-facilitator path above: + // passing `facilitator=integrated_vendor_xxx` explicitly skips + // this auto-resolve block entirely. + // ───────────────────────────────────────────────────────────────── + $autoResolveOrder = null; + if ($orderPublicId = $request->input('order')) { + $autoResolveOrder = Order::with('customer') + ->where('public_id', $orderPublicId) + ->first(); + } + + $providerFilter = $request->input('providers'); + if (is_string($providerFilter) && $providerFilter !== '') { + $providerFilter = array_filter(array_map('trim', explode(',', $providerFilter))); + } elseif (!is_array($providerFilter)) { + $providerFilter = null; + } + + $companyUuid = $request->session()->get('company'); + if ($companyUuid && ($autoResolveOrder !== null || $providerFilter !== null)) { + $resolvedVendors = IntegratedVendorResolver::resolveForQuoteRequest( + (string) $companyUuid, + $autoResolveOrder, + $providerFilter + ); + + if (count($resolvedVendors) > 0) { + $aggregated = []; + foreach ($resolvedVendors as $vendor) { + try { + $fromBridge = $vendor->api() + ->setRequestId($requestId) + ->getQuoteFromPayload($payload, $serviceType, $scheduledAt, $isRouteOptimized); + if (!is_array($fromBridge)) { + $fromBridge = [$fromBridge]; + } + $aggregated = array_merge($aggregated, $fromBridge); + } catch (\Exception $e) { + // Per-vendor failure should not abort the batch. + // Swallow + continue so a single misconfigured + // carrier credential doesn't block the others. + // The pure-helper resolver already filtered out + // provider rows that had no viable credential + // candidate, so anything that throws here is an + // upstream carrier error worth surfacing through + // observability rather than a 400 response. + report($e); + continue; + } + } + + if ($single) { + return response()->json($aggregated); + } + return response()->json($aggregated); + } + } + // get all waypoints $waypoints = $payload->getAllStops()->mapInto(Place::class); diff --git a/server/src/Support/IntegratedVendorResolver.php b/server/src/Support/IntegratedVendorResolver.php new file mode 100644 index 00000000..f6fc0f10 --- /dev/null +++ b/server/src/Support/IntegratedVendorResolver.php @@ -0,0 +1,176 @@ + $candidates + * @param ?string $shipperClientUuid Vendor uuid of the order's shipper-client, or null. + * @param ?array $providerFilter When non-empty, restricts to these provider codes only. + * + * @return array ordered by the iteration order of $candidates; deterministic + */ + public static function chooseVendorUuids( + array $candidates, + ?string $shipperClientUuid = null, + ?array $providerFilter = null + ): array { + // Normalize provider filter: null or [] => no filter. + $filterActive = is_array($providerFilter) && count($providerFilter) > 0; + $filterSet = $filterActive + ? array_flip(array_map('strval', $providerFilter)) + : null; + + // Bucket candidates by provider, preserving first-seen order so the + // final result is deterministic regardless of how the DB returned rows. + $byProvider = []; + $providerOrder = []; + foreach ($candidates as $candidate) { + $provider = isset($candidate['provider']) ? (string) $candidate['provider'] : ''; + if ($provider === '') { + continue; + } + if ($filterActive && !isset($filterSet[$provider])) { + continue; + } + if (!isset($byProvider[$provider])) { + $byProvider[$provider] = []; + $providerOrder[] = $provider; + } + $byProvider[$provider][] = $candidate; + } + + $chosen = []; + foreach ($providerOrder as $provider) { + $group = $byProvider[$provider]; + + // Prefer a client-specific match when the request carries a + // shipperClientUuid. Matching is exact-string. + $clientSpecific = null; + $catchAll = null; + foreach ($group as $candidate) { + $rowShipper = $candidate['shipper_client_uuid'] ?? null; + if ($shipperClientUuid !== null && $rowShipper === $shipperClientUuid) { + $clientSpecific = $candidate; + break; // first match wins; no need to scan further for this provider + } + if ($catchAll === null && $rowShipper === null) { + $catchAll = $candidate; + } + } + + $pick = $clientSpecific ?? $catchAll; + if ($pick !== null && isset($pick['uuid'])) { + $chosen[] = (string) $pick['uuid']; + } + // else: neither a client-specific match nor a catch-all exists + // for this provider — skip it silently per the resolution + // rule. Never route through a mismatched + // shipper_client_uuid; that would bill the wrong account. + } + + return $chosen; + } + + /** + * Impure wrapper: query IntegratedVendor records scoped to a company, + * run the pure chooser, and hydrate back to IntegratedVendor models + * ready for ->api()->getQuoteFromPayload(...). + * + * The order's `customer_type`/`customer_uuid` determines the shipper + * client uuid when the customer is a Vendor; otherwise null is passed + * and only catch-all credentials are considered. + * + * @param string $companyUuid the fleetops company_uuid scoping the lookup + * @param ?Order $order the order this quote request is for, if available + * @param ?array $providerFilter optional list of provider codes to restrict to + * + * @return array + */ + public static function resolveForQuoteRequest( + string $companyUuid, + ?Order $order = null, + ?array $providerFilter = null + ): array { + // Pull every IntegratedVendor the company owns, then let the pure + // chooser apply the resolution rule. This is fine for Phase 2 scale + // (a broker has at most a few dozen carrier credential rows); if it + // becomes a hot path later, swap in a WHERE IN (provider_filter) + // query with a composite index hit on (company_uuid, provider, + // shipper_client_uuid). + $query = IntegratedVendor::where('company_uuid', $companyUuid); + if (is_array($providerFilter) && count($providerFilter) > 0) { + $query->whereIn('provider', $providerFilter); + } + $rows = $query->get(['uuid', 'provider', 'shipper_client_uuid']); + + $candidates = $rows->map(fn ($row) => [ + 'uuid' => (string) $row->uuid, + 'provider' => (string) $row->provider, + 'shipper_client_uuid' => $row->shipper_client_uuid !== null + ? (string) $row->shipper_client_uuid + : null, + ])->all(); + + $shipperClientUuid = null; + if ($order !== null && ($order->customer_type === 'vendor' || str_contains((string) $order->customer_type, 'Vendor'))) { + $shipperClientUuid = $order->customer_uuid !== null ? (string) $order->customer_uuid : null; + } + + $chosenUuids = self::chooseVendorUuids($candidates, $shipperClientUuid, $providerFilter); + if (empty($chosenUuids)) { + return []; + } + + // Preserve the ordering produced by chooseVendorUuids so the + // ServiceQuote response has a stable sort. + $indexed = IntegratedVendor::whereIn('uuid', $chosenUuids)->get()->keyBy('uuid'); + $resolved = []; + foreach ($chosenUuids as $uuid) { + if (isset($indexed[$uuid])) { + $resolved[] = $indexed[$uuid]; + } + } + + return $resolved; + } +} diff --git a/server/tests/Support/IntegratedVendorResolverTest.php b/server/tests/Support/IntegratedVendorResolverTest.php new file mode 100644 index 00000000..5c376a3c --- /dev/null +++ b/server/tests/Support/IntegratedVendorResolverTest.php @@ -0,0 +1,237 @@ + $uuid, + 'provider' => $provider, + 'shipper_client_uuid' => $shipperClientUuid, + ]; +} + +// ── Exact match vs fallback ────────────────────────────────────────────── + +test('prefers client-specific match when it exists for the shipper client', function () { + $candidates = [ + cand('uuid-default-ups', 'ups', null), + cand('uuid-acme-ups', 'ups', 'acme-vendor-uuid'), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: 'acme-vendor-uuid' + ); + + expect($chosen)->toBe(['uuid-acme-ups']); +}); + +test('falls back to the null shipper_client_uuid catch-all when no client-specific match exists', function () { + $candidates = [ + cand('uuid-default-ups', 'ups', null), + cand('uuid-other-ups', 'ups', 'other-vendor-uuid'), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: 'acme-vendor-uuid' + ); + + expect($chosen)->toBe(['uuid-default-ups']); +}); + +test('chooses client-specific match even when catch-all comes first in candidate list', function () { + // Ensures ordering is not load-bearing on the resolution rule. + $candidates = [ + cand('uuid-default-ups', 'ups', null), + cand('uuid-other-ups', 'ups', 'other-vendor-uuid'), + cand('uuid-acme-ups', 'ups', 'acme-vendor-uuid'), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: 'acme-vendor-uuid' + ); + + expect($chosen)->toBe(['uuid-acme-ups']); +}); + +// ── Multiple providers ─────────────────────────────────────────────────── + +test('resolves across multiple providers — one row per provider', function () { + $candidates = [ + cand('uuid-default-parcelpath', 'parcelpath', null), + cand('uuid-default-ups', 'ups', null), + cand('uuid-default-usps', 'usps', null), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: null + ); + + expect($chosen)->toHaveCount(3); + expect($chosen)->toContain('uuid-default-parcelpath'); + expect($chosen)->toContain('uuid-default-ups'); + expect($chosen)->toContain('uuid-default-usps'); +}); + +test('resolves multiple providers with mixed client-specific and fallback matches', function () { + $candidates = [ + // ParcelPath only has the catch-all + cand('uuid-default-parcelpath', 'parcelpath', null), + // UPS has both a catch-all and a client-specific + cand('uuid-default-ups', 'ups', null), + cand('uuid-acme-ups', 'ups', 'acme-vendor-uuid'), + // USPS only has a client-specific (no catch-all) + cand('uuid-acme-usps', 'usps', 'acme-vendor-uuid'), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: 'acme-vendor-uuid' + ); + + expect($chosen)->toHaveCount(3); + expect($chosen)->toContain('uuid-default-parcelpath'); + expect($chosen)->toContain('uuid-acme-ups'); + expect($chosen)->toContain('uuid-acme-usps'); + expect($chosen)->not->toContain('uuid-default-ups'); +}); + +// ── Missing provider handling ──────────────────────────────────────────── + +test('silently skips a provider when it has no candidates at all', function () { + // UPS exists but USPS has no rows; USPS is simply absent from the result. + $candidates = [ + cand('uuid-default-ups', 'ups', null), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: null + ); + + expect($chosen)->toBe(['uuid-default-ups']); +}); + +test('silently skips a provider when no candidate matches the shipper and no catch-all exists', function () { + // UPS only has an other-client row; no catch-all. The request comes in + // for acme — UPS cannot be resolved for this shipper client and must + // be dropped rather than routing through the wrong account. + $candidates = [ + cand('uuid-default-parcelpath', 'parcelpath', null), + cand('uuid-other-ups', 'ups', 'other-vendor-uuid'), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: 'acme-vendor-uuid' + ); + + expect($chosen)->toBe(['uuid-default-parcelpath']); +}); + +test('returns empty array when candidate list is empty', function () { + expect(IntegratedVendorResolver::chooseVendorUuids( + candidates: [], + shipperClientUuid: 'acme-vendor-uuid' + ))->toBe([]); +}); + +// ── Null shipper client — direct-customer / non-broker case ──────────── + +test('null shipper client uses only whereNull candidates', function () { + // When the order's customer is not a Vendor (e.g. a Contact), we pass + // shipperClientUuid=null. The resolver must only consider catch-all + // rows, ignoring any client-specific credentials on the broker's + // account that were registered for other clients. + $candidates = [ + cand('uuid-default-ups', 'ups', null), + cand('uuid-acme-ups', 'ups', 'acme-vendor-uuid'), + cand('uuid-other-ups', 'ups', 'other-vendor-uuid'), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: null + ); + + expect($chosen)->toBe(['uuid-default-ups']); +}); + +// ── Provider filter ────────────────────────────────────────────────────── + +test('provider filter restricts the resolver to the requested carriers only', function () { + $candidates = [ + cand('uuid-default-parcelpath', 'parcelpath', null), + cand('uuid-default-ups', 'ups', null), + cand('uuid-default-usps', 'usps', null), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: null, + providerFilter: ['ups', 'usps'] + ); + + expect($chosen)->toHaveCount(2); + expect($chosen)->toContain('uuid-default-ups'); + expect($chosen)->toContain('uuid-default-usps'); + expect($chosen)->not->toContain('uuid-default-parcelpath'); +}); + +test('empty provider filter is treated as no filter (all providers allowed)', function () { + $candidates = [ + cand('uuid-default-parcelpath', 'parcelpath', null), + cand('uuid-default-ups', 'ups', null), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: null, + providerFilter: [] + ); + + expect($chosen)->toHaveCount(2); +}); + +test('null provider filter is treated as no filter', function () { + $candidates = [ + cand('uuid-default-parcelpath', 'parcelpath', null), + cand('uuid-default-ups', 'ups', null), + ]; + + $chosen = IntegratedVendorResolver::chooseVendorUuids( + $candidates, + shipperClientUuid: null, + providerFilter: null + ); + + expect($chosen)->toHaveCount(2); +}); + +// ── Determinism guard ─────────────────────────────────────────────────── + +test('result is deterministic regardless of candidate array order', function () { + $a = [ + cand('uuid-default-parcelpath', 'parcelpath', null), + cand('uuid-default-ups', 'ups', null), + cand('uuid-default-usps', 'usps', null), + ]; + $b = array_reverse($a); + + $resultA = IntegratedVendorResolver::chooseVendorUuids($a, shipperClientUuid: null); + $resultB = IntegratedVendorResolver::chooseVendorUuids($b, shipperClientUuid: null); + + sort($resultA); + sort($resultB); + expect($resultA)->toBe($resultB); +}); From d8ff27916db362b2275cf1e9a4e7e5da0a0eea47 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 21:24:42 -0700 Subject: [PATCH 24/25] feat(tracking): PollUPS + PollUSPS tracking jobs + UPS event mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two scheduled tracking poll jobs (PollUPSTrackingJob, PollUSPSTrackingJob) mirroring PollParcelPathTrackingJob from Phase 1 Task 9 in structure, error isolation, and idempotency semantics. Adds the UPS tracking normalizer and event code mapper that were missing from UPS.php (Tasks 14+15 covered rating/ship/void only). ## New pure helpers on UPS.php - upsActivityCodeToFleetbaseCode(string): string Maps UPS Tracking API activity type codes per spec §6.2: I -> IN_TRANSIT, D -> DELIVERED, X -> EXCEPTION, P -> PICKED_UP, M -> MANIFESTED, O -> OUT_FOR_DELIVERY, RS -> RETURN_TO_SENDER Case-insensitive. Unknown codes pass through uppercased. Symmetric with USPS::uspsEventTypeToFleetbaseCode() which already ships in Task 16. - normalizeTrackingResponse(array): array Parses trackResponse.shipment[0].package[0].activity[], maps each entry through the code mapper, builds location from city + stateProvince, derives timestamp from YYYYMMDD + HHMMSS date/time pair (UPS's native format). Handles the UPS quirk where a single activity comes back as a direct object not an array. Status = last event code. - getTrackingStatus(string $trackingNumber): array Impure wrapper — GET /api/track/v1/details/{trackingNumber} through the OAuth-authenticated Guzzle client, then normalizeTrackingResponse. ## New tests server/tests/Integrations/UPS/UPSTrackingNormalizerTest.php: - 11 pure Pest tests covering the activity code map (all 7 spec codes + case-insensitivity + unknown passthrough + empty string) - 5 pure Pest tests covering normalizeTrackingResponse (multi- event with location, RS -> RETURN_TO_SENDER, missing activity, single activity as object, missing location) NOTE: Pest was unresponsive via Docker in this session window. Tests are written and staged but have not been verified green in this commit. First session that can run `docker run --rm ... pest` should run the full suite and confirm. The test shapes mirror the proven Phase 1 + Phase 2 patterns and the code under test is direct copy of the USPS tracking normalizer (already green in Task 16) adapted for UPS's different response shape. ## PollUPSTrackingJob - Scope: orders in-flight + meta carries integrated_vendor_order.shipmentIdentificationNumber - Provider filter: facilitator provider = 'ups' - Bridge: UPS::getTrackingStatus(trackingNumber) - Per event: TrackingStatus::firstOrCreate (idempotent) - Terminal: reuses ParcelPath::terminalOrderStatus from Phase 1 (DELIVERED -> completed, RETURN_TO_SENDER -> returned) - Per-order errors: report() + continue (batch isolation) - $tries=1, $timeout=300 (same as ParcelPath job) - Scheduled everyFifteenMinutes()->withoutOverlapping() ## PollUSPSTrackingJob - Scope: orders in-flight + meta carries carrier='USPS' + tracking_number - Provider filter: facilitator provider = 'usps' - Bridge: USPS::getTrackingStatus(trackingNumber) (already existed in Task 16) - Event mapping: uses USPS::normalizeTrackingResponse which calls uspsEventTypeToFleetbaseCode (ALERT -> EXCEPTION; everything else verbatim) - Otherwise identical to the UPS job (same firstOrCreate, same terminal helper, same error isolation, same timeout) - Scheduled everyFifteenMinutes()->withoutOverlapping() ## FleetOpsServiceProvider Both jobs registered in the existing scheduleCommands closure alongside the ParcelPath job from Phase 1 Task 9. Three tracking poll jobs now run every 15 minutes — one per carrier provider. ## Carrier-specific quirks UPS: - Tracking API returns events in trackResponse.shipment[0].package[0].activity[]. This is triply nested. If the array is absent at any level, the normalizer returns UNKNOWN status + empty events (defensive). - Date/time are separate fields (YYYYMMDD / HHMMSS), not ISO timestamps. The normalizer composes them into YYYY-MM-DDTHH:MM:SS for Fleetbase's TrackingStatus.created_at column. - Single activity returned as object-not-array: same UPS quirk as single RatedShipment in rating. Handled explicitly. - UPS tracking number format: 1Z... (18 chars). Phase 2 scoped to 1Z tracking only; reference-number tracking (NBNL) is deferred. USPS: - Tracking API returns events in trackingEvents[] (flat, not triply nested). Events are chronological (oldest first). - ALERT -> EXCEPTION mapping is the only non-identity code transform. - USPS tracking numbers are 20+ digit numeric strings (9400..., 9200..., etc). ## Not in this commit - No live carrier API calls - No UI changes - No credentials required - The USPS bridge's getTrackingStatus already existed (Task 16); only the job wrapping it and the scheduler registration are new --- server/src/Integrations/UPS/UPS.php | 124 ++++++++++++++ server/src/Jobs/PollUPSTrackingJob.php | 109 ++++++++++++ server/src/Jobs/PollUSPSTrackingJob.php | 111 +++++++++++++ .../src/Providers/FleetOpsServiceProvider.php | 6 + .../UPS/UPSTrackingNormalizerTest.php | 157 ++++++++++++++++++ 5 files changed, 507 insertions(+) create mode 100644 server/src/Jobs/PollUPSTrackingJob.php create mode 100644 server/src/Jobs/PollUSPSTrackingJob.php create mode 100644 server/tests/Integrations/UPS/UPSTrackingNormalizerTest.php diff --git a/server/src/Integrations/UPS/UPS.php b/server/src/Integrations/UPS/UPS.php index b7f8078c..cb92d042 100644 --- a/server/src/Integrations/UPS/UPS.php +++ b/server/src/Integrations/UPS/UPS.php @@ -576,6 +576,116 @@ public static function normalizeVoidResponse(array $response): bool return false; } + /** + * Map a UPS Tracking API activity type code to the corresponding + * Fleetbase TrackingStatus code. Per the integration spec §6.2. + * + * | UPS Code | Fleetbase Code | + * |----------|---------------------| + * | I | IN_TRANSIT | + * | D | DELIVERED | + * | X | EXCEPTION | + * | P | PICKED_UP | + * | M | MANIFESTED | + * | O | OUT_FOR_DELIVERY | + * | RS | RETURN_TO_SENDER | + * + * Unknown codes pass through uppercased — the caller can decide how + * to handle them. + */ + public static function upsActivityCodeToFleetbaseCode(string $code): string + { + if ($code === '') { + return ''; + } + + return match (strtoupper($code)) { + 'I' => 'IN_TRANSIT', + 'D' => 'DELIVERED', + 'X' => 'EXCEPTION', + 'P' => 'PICKED_UP', + 'M' => 'MANIFESTED', + 'O' => 'OUT_FOR_DELIVERY', + 'RS' => 'RETURN_TO_SENDER', + default => strtoupper($code), + }; + } + + /** + * Normalize a UPS Tracking API v1 response + * (GET /api/track/v1/details/{trackingNumber}) into the Fleetbase + * tracking shape: {status, carrier, events[]}. + * + * UPS returns activity entries inside + * trackResponse.shipment[0].package[0].activity[]. Each entry has + * status.type (the activity code), date (YYYYMMDD), time (HHMMSS), + * and optionally location.address.{city, stateProvince}. + * + * Handles the UPS quirk where a single activity may be returned + * as an object rather than an array of one. + * + * Status is derived from the last event (UPS returns events in + * chronological order with the most recent last). + */ + public static function normalizeTrackingResponse(array $response): array + { + $activities = $response['trackResponse']['shipment'][0]['package'][0]['activity'] ?? null; + + if (!is_array($activities) || empty($activities)) { + return [ + 'status' => 'UNKNOWN', + 'carrier' => 'UPS', + 'events' => [], + ]; + } + + // Single activity may be an object not an array. + if (isset($activities['status']) || isset($activities['date'])) { + $activities = [$activities]; + } + + $events = []; + foreach ($activities as $activity) { + $rawCode = (string) ($activity['status']['type'] ?? ''); + $code = self::upsActivityCodeToFleetbaseCode($rawCode); + + $city = $activity['location']['address']['city'] ?? null; + $state = $activity['location']['address']['stateProvince'] ?? null; + $location = null; + if ($city !== null && $state !== null) { + $location = $city . ', ' . $state; + } elseif ($city !== null) { + $location = $city; + } + + $dateStr = (string) ($activity['date'] ?? ''); + $timeStr = (string) ($activity['time'] ?? ''); + $timestamp = null; + if (strlen($dateStr) === 8) { + $timestamp = substr($dateStr, 0, 4) . '-' . substr($dateStr, 4, 2) . '-' . substr($dateStr, 6, 2); + if (strlen($timeStr) === 6) { + $timestamp .= 'T' . substr($timeStr, 0, 2) . ':' . substr($timeStr, 2, 2) . ':' . substr($timeStr, 4, 2); + } + } + + $events[] = [ + 'code' => $code, + 'status' => (string) ($activity['status']['description'] ?? $code), + 'timestamp' => $timestamp, + 'location' => $location, + 'details' => null, + ]; + } + + $finalCode = end($events)['code'] ?? 'UNKNOWN'; + + return [ + 'status' => $finalCode, + 'carrier' => 'UPS', + 'events' => $events, + ]; + } + // ───────────────────────────────────────────────────────────────────── // IMPURE RUNTIME WRAPPERS — compose pure helpers + HTTP + Eloquent. // Not unit-tested in this phase; exercised via smoke test once the @@ -656,6 +766,20 @@ public function getQuoteFromPayload( return $quotes; } + /** + * Get tracking status from UPS Tracking API v1. + */ + public function getTrackingStatus(string $trackingNumber): array + { + $response = $this->request( + 'GET', + '/api/track/v1/details/' . rawurlencode($trackingNumber), + [] + ) ?? []; + + return static::normalizeTrackingResponse($response); + } + /** * Purchase a UPS shipping label against the POST /api/shipments/v2409/ship * endpoint. Decodes the base64 label binary, writes it to the default diff --git a/server/src/Jobs/PollUPSTrackingJob.php b/server/src/Jobs/PollUPSTrackingJob.php new file mode 100644 index 00000000..26d17770 --- /dev/null +++ b/server/src/Jobs/PollUPSTrackingJob.php @@ -0,0 +1,109 @@ +whereIn('status', ['dispatched', 'in_transit', 'out_for_delivery']) + ->whereNotNull('meta->integrated_vendor_order->shipmentIdentificationNumber') + ->get(); + + foreach ($orders as $order) { + try { + $this->pollOrder($order); + } catch (Throwable $e) { + report($e); + continue; + } + } + } + + protected function pollOrder(Order $order): void + { + $vendor = IntegratedVendor::find($order->facilitator_uuid); + if (!$vendor || $vendor->provider !== 'ups') { + return; + } + + $bridge = $vendor->api(); + if (!$bridge instanceof UPS) { + return; + } + + $trackingNumber = $order->getMeta('integrated_vendor_order.tracking_number') + ?? $order->getMeta('integrated_vendor_order.shipmentIdentificationNumber'); + if (!$trackingNumber) { + return; + } + + $result = $bridge->getTrackingStatus($trackingNumber); + $trackingNumberModel = $order->trackingNumber; + if (!$trackingNumberModel) { + return; + } + + foreach ($result['events'] as $event) { + TrackingStatus::firstOrCreate( + [ + 'tracking_number_uuid' => $trackingNumberModel->uuid, + 'code' => $event['code'], + 'created_at' => $event['timestamp'] ?: now(), + ], + [ + 'company_uuid' => $order->company_uuid, + 'status' => $event['status'] ?: $event['code'], + 'details' => $event['location'] ?? $event['details'] ?? null, + ] + ); + } + + // Reuse the shared terminal-status helper from ParcelPath (Phase 1 Task 9). + // DELIVERED -> 'completed', RETURN_TO_SENDER -> 'returned', anything else -> null. + $terminal = ParcelPath::terminalOrderStatus($result['status'] ?? ''); + if ($terminal && $order->status !== $terminal) { + $order->status = $terminal; + $order->save(); + } + } +} diff --git a/server/src/Jobs/PollUSPSTrackingJob.php b/server/src/Jobs/PollUSPSTrackingJob.php new file mode 100644 index 00000000..3c2171d9 --- /dev/null +++ b/server/src/Jobs/PollUSPSTrackingJob.php @@ -0,0 +1,111 @@ + EXCEPTION; all others pass through verbatim). + * + * Event code mapping: USPS::uspsEventTypeToFleetbaseCode() is the + * public pure helper that performs the mapping, and it's already + * unit-tested in USPSLabelBuilderTest.php. + * + * Terminal status transitions: reuses ParcelPath::terminalOrderStatus() + * (the shared helper from Phase 1 Task 9) which maps DELIVERED -> + * 'completed' and RETURN_TO_SENDER / RETURNED -> 'returned'. + */ +class PollUSPSTrackingJob implements ShouldQueue +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; + + public int $tries = 1; + public int $timeout = 300; + + public function handle(): void + { + $orders = Order::query() + ->whereIn('status', ['dispatched', 'in_transit', 'out_for_delivery']) + ->where(function ($q) { + // USPS orders store carrier='USPS' in meta + $q->where('meta->integrated_vendor_order->carrier', 'USPS'); + }) + ->whereNotNull('meta->integrated_vendor_order->tracking_number') + ->get(); + + foreach ($orders as $order) { + try { + $this->pollOrder($order); + } catch (Throwable $e) { + report($e); + continue; + } + } + } + + protected function pollOrder(Order $order): void + { + $vendor = IntegratedVendor::find($order->facilitator_uuid); + if (!$vendor || $vendor->provider !== 'usps') { + return; + } + + $bridge = $vendor->api(); + if (!$bridge instanceof USPS) { + return; + } + + $trackingNumber = $order->getMeta('integrated_vendor_order.tracking_number'); + if (!$trackingNumber) { + return; + } + + $result = $bridge->getTrackingStatus($trackingNumber); + $trackingNumberModel = $order->trackingNumber; + if (!$trackingNumberModel) { + return; + } + + foreach ($result['events'] as $event) { + TrackingStatus::firstOrCreate( + [ + 'tracking_number_uuid' => $trackingNumberModel->uuid, + 'code' => $event['code'], + 'created_at' => $event['timestamp'] ?: now(), + ], + [ + 'company_uuid' => $order->company_uuid, + 'status' => $event['status'] ?: $event['code'], + 'details' => $event['location'] ?? $event['details'] ?? null, + ] + ); + } + + $terminal = ParcelPath::terminalOrderStatus($result['status'] ?? ''); + if ($terminal && $order->status !== $terminal) { + $order->status = $terminal; + $order->save(); + } + } +} diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index 53d26b9f..0497a1de 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -101,6 +101,12 @@ public function boot() $schedule->job(new \Fleetbase\FleetOps\Jobs\PollParcelPathTrackingJob()) ->everyFifteenMinutes() ->withoutOverlapping(); + $schedule->job(new \Fleetbase\FleetOps\Jobs\PollUPSTrackingJob()) + ->everyFifteenMinutes() + ->withoutOverlapping(); + $schedule->job(new \Fleetbase\FleetOps\Jobs\PollUSPSTrackingJob()) + ->everyFifteenMinutes() + ->withoutOverlapping(); }); $this->registerNotifications(); $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); diff --git a/server/tests/Integrations/UPS/UPSTrackingNormalizerTest.php b/server/tests/Integrations/UPS/UPSTrackingNormalizerTest.php new file mode 100644 index 00000000..fa751052 --- /dev/null +++ b/server/tests/Integrations/UPS/UPSTrackingNormalizerTest.php @@ -0,0 +1,157 @@ +toBe('IN_TRANSIT'); +}); + +test('D maps to DELIVERED', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('D'))->toBe('DELIVERED'); +}); + +test('X maps to EXCEPTION', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('X'))->toBe('EXCEPTION'); +}); + +test('P maps to PICKED_UP', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('P'))->toBe('PICKED_UP'); +}); + +test('M maps to MANIFESTED', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('M'))->toBe('MANIFESTED'); +}); + +test('O maps to OUT_FOR_DELIVERY', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('O'))->toBe('OUT_FOR_DELIVERY'); +}); + +test('RS maps to RETURN_TO_SENDER', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('RS'))->toBe('RETURN_TO_SENDER'); +}); + +test('code mapping is case-insensitive', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('d'))->toBe('DELIVERED'); + expect(UPS::upsActivityCodeToFleetbaseCode('rs'))->toBe('RETURN_TO_SENDER'); + expect(UPS::upsActivityCodeToFleetbaseCode('i'))->toBe('IN_TRANSIT'); +}); + +test('unknown codes pass through uppercased', function () { + expect(UPS::upsActivityCodeToFleetbaseCode('Z'))->toBe('Z'); + expect(UPS::upsActivityCodeToFleetbaseCode('foo'))->toBe('FOO'); +}); + +test('empty string returns empty string', function () { + expect(UPS::upsActivityCodeToFleetbaseCode(''))->toBe(''); +}); + +// ── normalizeTrackingResponse ──────────────────────────────────────────── + +test('normalizeTrackingResponse maps UPS activity codes and extracts location', function () { + $resp = [ + 'trackResponse' => [ + 'shipment' => [[ + 'package' => [[ + 'activity' => [ + [ + 'status' => ['type' => 'I', 'description' => 'In Transit'], + 'location' => ['address' => ['city' => 'Louisville', 'stateProvince' => 'KY']], + 'date' => '20260407', + 'time' => '103000', + ], + [ + 'status' => ['type' => 'D', 'description' => 'Delivered'], + 'location' => ['address' => ['city' => 'New York', 'stateProvince' => 'NY']], + 'date' => '20260409', + 'time' => '142200', + ], + ], + ]], + ]], + ], + ]; + + $result = UPS::normalizeTrackingResponse($resp); + + expect($result['carrier'])->toBe('UPS'); + expect($result['events'])->toHaveCount(2); + + expect($result['events'][0]['code'])->toBe('IN_TRANSIT'); + expect($result['events'][0]['location'])->toBe('Louisville, KY'); + expect($result['events'][0]['timestamp'])->toBe('2026-04-07T10:30:00'); + + expect($result['events'][1]['code'])->toBe('DELIVERED'); + expect($result['events'][1]['location'])->toBe('New York, NY'); + + // Status is derived from the last event + expect($result['status'])->toBe('DELIVERED'); +}); + +test('normalizeTrackingResponse handles RS (return to sender)', function () { + $resp = [ + 'trackResponse' => [ + 'shipment' => [[ + 'package' => [[ + 'activity' => [[ + 'status' => ['type' => 'RS', 'description' => 'Returned'], + 'date' => '20260410', + 'time' => '080000', + ]], + ]], + ]], + ], + ]; + + $result = UPS::normalizeTrackingResponse($resp); + expect($result['events'][0]['code'])->toBe('RETURN_TO_SENDER'); + expect($result['status'])->toBe('RETURN_TO_SENDER'); +}); + +test('normalizeTrackingResponse returns UNKNOWN status when activity is missing', function () { + expect(UPS::normalizeTrackingResponse([]))->toBe([ + 'status' => 'UNKNOWN', + 'carrier' => 'UPS', + 'events' => [], + ]); +}); + +test('normalizeTrackingResponse handles single activity as object (not array)', function () { + $resp = [ + 'trackResponse' => [ + 'shipment' => [[ + 'package' => [[ + 'activity' => [ + 'status' => ['type' => 'D', 'description' => 'Delivered'], + 'date' => '20260409', + 'time' => '120000', + ], + ]], + ]], + ], + ]; + + $result = UPS::normalizeTrackingResponse($resp); + expect($result['events'])->toHaveCount(1); + expect($result['events'][0]['code'])->toBe('DELIVERED'); +}); + +test('normalizeTrackingResponse handles missing location gracefully', function () { + $resp = [ + 'trackResponse' => [ + 'shipment' => [[ + 'package' => [[ + 'activity' => [[ + 'status' => ['type' => 'I'], + 'date' => '20260407', + 'time' => '100000', + ]], + ]], + ]], + ], + ]; + + $result = UPS::normalizeTrackingResponse($resp); + expect($result['events'][0]['location'])->toBeNull(); +}); From 27a3a47d2ad6713c1c07507d6091ac727991b577 Mon Sep 17 00:00:00 2001 From: Tucker Lemm Date: Thu, 9 Apr 2026 22:40:54 -0700 Subject: [PATCH 25/25] feat(ui): shipper client selector on IntegratedVendor form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional Shipper Client vendor lookup to the IntegratedVendor form, completing the broker auto-resolution chain started by the shipper_client_uuid migration (Task 18) and the ServiceQuoteController auto-resolve logic (Task 19). ## UI change A new "Shipper Client" ContentPanel renders between the Options and Advanced Options panels on the IV form. It contains a bound to the company's Vendor records via @modelName="vendor" — the same component and pattern already used in: - driver/form.hbs (assign vendor to driver) - modals/place-assign-vendor.hbs - modals/driver-assign-vendor.hbs - part/form.hbs Behavior: - Selecting a vendor sets @resource.shipper_client_uuid to the vendor's id (UUID). The API serializer persists this to the integrated_vendors.shipper_client_uuid column added in Task 18. - Clearing the selection (via @allowClear={{true}}) sets the UUID to null, making this record the broker-level catch-all for its provider. - On edit (existing IV record with a non-null shipper_client_uuid), the form.js constructor asynchronously resolves the Vendor model from the Ember Data store so the renders the vendor's name rather than showing empty. Uses peekRecord first for cache-friendliness, falls back to findRecord. ## form.js changes - Injects @service store - Adds @tracked selectedShipperClient (null by default) - Adds _loadExistingShipperClient() async helper called from constructor — resolves the Vendor when editing an existing IV - Adds @action setShipperClient(vendor) — sets uuid on resource and updates the tracked property ## Translations (en-us) - integrated-vendor.fields.shipper-client: "Shipper Client" - integrated-vendor.fields.shipper-client-label: "Shipper Client (optional)" - integrated-vendor.fields.shipper-client-placeholder: "Select a shipper client..." - integrated-vendor.fields.shipper-client-help-text: explains the scoping behavior and catch-all default ## No backend changes The PHP model (IntegratedVendor) already has shipper_client_uuid in $fillable and the shipperClient() relationship method — both landed in Task 18. The API serializer already exposes the column. No backend changes are needed for this UI wiring. ## No Ember tests The existing tests/integration/components/integrated-vendor/form-test.js is an auto-generated boilerplate placeholder with no meaningful assertions. Follows the Phase 1 precedent (Tasks 10 + 11 shipped with no Ember component tests). Manual verification path documented below. ## Manual verification path 1. Start the stack (docker compose up) + Ember console dev server 2. Navigate to Fleet-Ops → Management → Integrated Vendors → New 3. Pick UPS (or any provider) 4. Scroll to the "Shipper Client" panel — a dropdown should appear showing all company Vendor records 5. Select a vendor → save → verify shipper_client_uuid is populated on the IntegratedVendor record (check via API or tinker) 6. Edit the same record → verify the vendor name appears pre-selected in the dropdown 7. Clear the selection → save → verify shipper_client_uuid is null 8. Create a second IV for the same provider with a different vendor or no vendor (catch-all) → verify auto-resolve picks the right one based on order customer (smoke test) --- addon/components/integrated-vendor/form.hbs | 20 ++++++++ addon/components/integrated-vendor/form.js | 52 +++++++++++++++++++++ translations/en-us.yaml | 4 ++ 3 files changed, 76 insertions(+) diff --git a/addon/components/integrated-vendor/form.hbs b/addon/components/integrated-vendor/form.hbs index e2917b54..bdf4f989 100644 --- a/addon/components/integrated-vendor/form.hbs +++ b/addon/components/integrated-vendor/form.hbs @@ -55,6 +55,26 @@
+ +
+ + + {{model.name}} + + +
+
reflects the + * current state. Loaded from the store on first render when + * shipper_client_uuid is already set on the resource (edit flow). + */ + @tracked selectedShipperClient = null; + + constructor() { + super(...arguments); + this._loadExistingShipperClient(); + } + + /** + * If the resource already has a shipper_client_uuid (e.g. editing + * an existing IntegratedVendor), resolve the Vendor model from the + * store so the renders the name rather than showing + * "empty". + */ + async _loadExistingShipperClient() { + const uuid = this.args.resource?.shipper_client_uuid; + if (!uuid) { + return; + } + try { + // peekRecord first (might already be in the store from a previous load) + let vendor = this.store.peekRecord('vendor', uuid); + if (!vendor) { + vendor = await this.store.findRecord('vendor', uuid); + } + this.selectedShipperClient = vendor; + } catch { + // Vendor may have been deleted or is inaccessible — leave the + // selector empty; the raw UUID on the resource is preserved. + } + } + @action toggleAdvancedOptions() { this.showAdvancedOptions = !this.showAdvancedOptions; } + + /** + * Called by the when the user picks a Vendor or clears + * the selection. Sets the raw UUID on the resource (which the API + * serializer will persist) and updates the tracked property so the + * dropdown reflects the change. + */ + @action setShipperClient(vendor) { + this.selectedShipperClient = vendor; + if (this.args.resource) { + this.args.resource.shipper_client_uuid = vendor?.id ?? null; + } + } } diff --git a/translations/en-us.yaml b/translations/en-us.yaml index a671c491..89ff65a3 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -944,6 +944,10 @@ integrated-vendor: host-help-text: Optionally provide a custom host that should be used for this integration. namespace: Custom Namespace namespace-help-text: Optionally provide a custom namespace or api version that should be used for the integration. + shipper-client: Shipper Client + shipper-client-label: Shipper Client (optional) + shipper-client-placeholder: Select a shipper client... + shipper-client-help-text: Scope this carrier credential to a specific shipper client. When set, the auto-resolver uses this record only for orders whose customer matches this vendor. Leave blank to create a catch-all record that handles any order without a client-specific credential. zone: fields: