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 --}} +
+
+
+ ParcelPath +
+
+

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

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

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

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

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

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

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

+
+
diff --git a/addon/components/carrier-onboarding-panel.js b/addon/components/carrier-onboarding-panel.js new file mode 100644 index 00000000..66fa9180 --- /dev/null +++ b/addon/components/carrier-onboarding-panel.js @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +/** + * CarrierOnboardingPanel + * + * Onboarding surface that nudges new operators toward ParcelPath as the + * default ParcelPath/UPS/USPS integration path while still exposing the + * direct UPS and USPS bridges for shippers with their own carrier + * contracts. Uses the existing IntegratedVendor create flow — clicking + * a "Connect" button hands the chosen provider to + * `integratedVendorActions.create`, which routes to the same form the + * Settings → Integrated Vendors page already renders. + */ +export default class CarrierOnboardingPanelComponent extends Component { + @service integratedVendorActions; + @service router; + + @action + connectProvider(providerCode) { + // Prefer the existing IntegratedVendor create action if it exposes + // a `create` modal/form (mirrors how the settings page wires up + // new vendors). Fall back to a route transition with a query + // param so the IntegratedVendor form can preselect the provider. + if ( + this.integratedVendorActions && + typeof this.integratedVendorActions.create === 'function' + ) { + return this.integratedVendorActions.create({ provider: providerCode }); + } + return this.router.transitionTo( + 'console.fleet-ops.management.integrated-vendors.new', + { queryParams: { provider: providerCode } } + ); + } +} diff --git a/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|}} -
+
- + {{#if serviceQuote.meta.carrier}} + {{serviceQuote.meta.carrier}} + + {{else}} + + {{/if}} - {{serviceQuote.request_id}} + {{or serviceQuote.meta.carrier serviceQuote.request_id}}
diff --git a/addon/components/order/form/service-rate.js b/addon/components/order/form/service-rate.js index 23c379af..45f71b1d 100644 --- a/addon/components/order/form/service-rate.js +++ b/addon/components/order/form/service-rate.js @@ -13,9 +13,29 @@ export default class OrderFormServiceRateComponent extends Component { return this.args.resource?.order_config && this.args.resource?.payloadCoordinates?.length >= 2; } + /** + * True when the order's facilitator is an IntegratedVendor (e.g. + * ParcelPath / UPS Direct / USPS Direct). For these, the bridge layer + * resolves rates server-side from the vendor's API instead of from + * locally configured ServiceRate records, so the rate-selector + * dropdown is hidden and quotes load directly when the toggle flips + * on. + */ + get isIntegratedVendorFacilitator() { + return this.args.resource?.facilitator?.get?.('isIntegratedVendor') ?? false; + } + @task *queryServiceRates(toggled) { this.args.resource.servicable = toggled; if (!toggled) return; + + // Integrated-vendor path: skip the local ServiceRate query and fetch + // quotes straight from the bound vendor (ParcelPath / UPS / USPS). + if (this.isIntegratedVendorFacilitator) { + yield this.getServiceQuotes.perform(null); + return; + } + this.serviceRates = yield this.serviceRateActions.queryServiceRatesForOrder.perform(this.args.resource); } diff --git a/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.