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
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..f6deeead
--- /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
+`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.
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 --}}
+
+
+
+
+
+
+
{{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"}}
+
+
+
+
+
+
{{t "carrier-onboarding.direct.ups"}}
+
+
+
+
+
+
+
{{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/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/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|}}
-
+
-
{{serviceQuote.public_id}}
+ {{#if serviceQuote.meta.carrier}}
+
+
+
+ {{or serviceQuote.meta.service_token serviceQuote.public_id}}
+
+
+ {{serviceQuote.meta.carrier}}
+ {{#if serviceQuote.meta.estimated_days}}
+ · {{t "order.fields.estimated-days" days=serviceQuote.meta.estimated_days}}
+ {{/if}}
+ {{#if this.isIntegratedVendorFacilitator}}
+ · {{t "order.fields.via-facilitator" facilitator=@resource.facilitator.name}}
+ {{/if}}
+
+
+ {{else}}
+
{{serviceQuote.public_id}}
+ {{/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/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/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');
+ });
+ }
+};
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/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':
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/Integrations/ParcelPath/ParcelPath.php b/server/src/Integrations/ParcelPath/ParcelPath.php
new file mode 100644
index 00000000..d4b1f25c
--- /dev/null
+++ b/server/src/Integrations/ParcelPath/ParcelPath.php
@@ -0,0 +1,481 @@
+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 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;
+ }
+
+ /**
+ * 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'] ?? []),
+ ];
+ }
+
+ /**
+ * 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,
+ ];
+ }
+
+ /**
+ * 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.
+ */
+ 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
+ // 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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/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/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/Integrations/UPS/UPS.php b/server/src/Integrations/UPS/UPS.php
new file mode 100644
index 00000000..cb92d042
--- /dev/null
+++ b/server/src/Integrations/UPS/UPS.php
@@ -0,0 +1,877 @@
+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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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
+ // 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;
+ }
+
+ /**
+ * 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
+ * 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/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/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/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/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/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/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/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php
index 452ab51e..0497a1de 100644
--- a/server/src/Providers/FleetOpsServiceProvider.php
+++ b/server/src/Providers/FleetOpsServiceProvider.php
@@ -98,6 +98,15 @@ 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();
+ $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/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/src/Support/IntegratedVendors.php b/server/src/Support/IntegratedVendors.php
index 4dca3061..6995803d 100644
--- a/server/src/Support/IntegratedVendors.php
+++ b/server/src/Support/IntegratedVendors.php
@@ -5,6 +5,12 @@
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\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;
@@ -231,6 +237,113 @@ 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' => [],
+ ],
+ [
+ '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' => [],
+ ],
+ [
+ '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/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');
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');
+});
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');
+});
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();
+});
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']);
+});
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();
+});
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();
+});
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);
+});
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);
+});
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');
+ }
+});
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();
+});
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');
+ }
+});
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);
+});
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();
+});
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([]);
+});
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();
+});
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');
+});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index 8affb124..89ff65a3 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.
@@ -942,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:
@@ -1716,3 +1722,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.