Skip to content

feat: ParcelPath + direct UPS/USPS integration (Phase 1 + Phase 2)#224

Open
TLemmAI wants to merge 25 commits intofleetbase:mainfrom
TLemmAI:feat/parcelpath-tms
Open

feat: ParcelPath + direct UPS/USPS integration (Phase 1 + Phase 2)#224
TLemmAI wants to merge 25 commits intofleetbase:mainfrom
TLemmAI:feat/parcelpath-tms

Conversation

@TLemmAI
Copy link
Copy Markdown

@TLemmAI TLemmAI commented Apr 9, 2026

Branch note: This PR evolved from a Phase 1-only branch into a combined Phase 1 + Phase 2 branch because the work is tightly coupled — Phase 2's direct carrier bridges extend Phase 1's ParcelPath bridge classes, registry entries, controller wiring, and tracking infrastructure. Both phases live on feat/parcelpath-tms and are not independently mergeable. The description below sections the work clearly so reviewers can follow the progression.

22 commits · 252 Pest tests passing (559 assertions, 1.92s) · Phase 1 smoke test 18/18 green.


Phase 1 — ParcelPath Mode A (Tasks 1–11)

Goal: Integrate UPS and USPS small-parcel shipping into FleetOps via the ParcelPath API — a single API key, zero carrier credentials, instant access to discounted UPS + USPS rates (60–89% below retail). This is the default path for operators who don't have their own carrier contracts.

Completed tasks

# Task SHA
1 ParcelPathServiceType enum (13 PP_* service keys) b15ea084
7 carrier_tracking_number migration on tracking_numbers 7e0c819b
6 Register ParcelPath in IntegratedVendors::$supported 3c0e5b4f
2 ParcelPath bridge scaffold (Guzzle client, injectable HandlerStack) 189dfe85
3 Rating: POST /v1/rates with pure builders + normalizers e8f6db1a
4 Label purchase: POST /v1/labels with File record write 5870f32d
5 Tracking + void normalizers 52d6aac4
8 LabelController carrier-label fallback before internal Blade label 6494c48a
9 PollParcelPathTrackingJob (every 15 min, terminal status transitions) 75eb7972
10 <CarrierOnboardingPanel /> Ember component f28623da
11 Order form rate selector enriched (carrier logo, ETA, source label) ae047558
.gitignore: ignore Pest workaround vendor symlink f0cce793

Phase 1 architecture

server/src/Integrations/ParcelPath/
├── ParcelPath.php                 ← bridge class (rate/label/track/void)
└── ParcelPathServiceType.php      ← 13-entry enum

server/src/Jobs/
└── PollParcelPathTrackingJob.php  ← 15-min scheduled poll

server/src/Http/Controllers/Api/v1/
└── LabelController.php            ← carrier-label fallback added

addon/components/
├── carrier-onboarding-panel.*     ← onboarding UI
└── order/form/service-rate.*      ← enriched rate display

Every bridge method follows a pure/impure split: pure static helpers (request builders, response normalizers) are Pest-testable without Laravel bootstrap; thin impure wrappers compose them with Guzzle + Eloquent.

Phase 1 validation

  • Pest: 85 passed at Phase 1 close
  • Smoke test: 18/18 green (SDK ↔ API ↔ console UI, full order lifecycle through completed)

Phase 2 — Direct UPS/USPS + Broker Routing + Tracking (Tasks 12–21)

Goal: Add Mode B — direct carrier accounts for UPS and USPS using the operator's own OAuth credentials and negotiated rates. Plus the broker-scale credential routing chain (shipper_client_uuid) that prevents billing the wrong carrier account when a broker manages multiple shipper clients.

Completed tasks

# Task SHA
13 UPSServiceType enum (8 service levels with numeric codes) 9602e709
12 UPSOAuthClient with Redis-cached bearer tokens 3ba788c1
18 shipper_client_uuid migration on integrated_vendors e40354a0
14 UPS Rate Shop rating (dim weight, negotiated rate preference, markup) 1f804f74
15 UPS createShipment + void (signature confirmation, label decode) d521ad7f
16 USPSServiceType + USPS v3 bridge (inline OAuth, PDF-only labels) c1a8e849
17 Register UPS + USPS in IntegratedVendors::$supported 8c7d495b + c7a53570
19 IntegratedVendorResolver + ServiceQuoteController auto-resolve e96465ad
20 Shipper Client <ModelSelect> on IntegratedVendor form 27a3a47d
21 PollUPSTrackingJob + PollUSPSTrackingJob + UPS event mapper d8ff2791

Phase 2 architecture

server/src/Integrations/UPS/
├── UPS.php              ← direct bridge (rate/ship/track/void)
├── UPSServiceType.php   ← 8-entry enum
└── UPSOAuthClient.php   ← OAuth2 with injectable cache

server/src/Integrations/USPS/
├── USPS.php             ← direct bridge (inline OAuth, PDF-only)
└── USPSServiceType.php  ← 5-entry enum

server/src/Support/
└── IntegratedVendorResolver.php  ← pure chooser + impure wrapper

server/src/Jobs/
├── PollUPSTrackingJob.php        ← 15-min scheduled poll
└── PollUSPSTrackingJob.php       ← 15-min scheduled poll

addon/components/integrated-vendor/
├── form.hbs  ← shipper client selector added
└── form.js   ← store injection + vendor resolution

Broker auto-resolve (the most architecturally significant addition)

IntegratedVendorResolver::chooseVendorUuids implements a three-step resolution rule per provider:

  1. Exact match — if the order's customer Vendor has a dedicated credential record, use it.
  2. Catch-all fallback — if no client-specific record exists but a default exists (shipper_client_uuid IS NULL), use it.
  3. Silent skip — if neither exists, drop the provider. Never route through a mismatched credential. This prevents billing the wrong carrier account.

Tested with 13 pure unit tests covering every combination. The ServiceQuoteController now supports three execution paths in priority order: explicit facilitator → auto-resolve → ServiceRate fallback.

Tracking jobs

Three carrier-specific poll jobs now run every 15 minutes (ParcelPath + UPS + USPS). All share the same structure: query active orders → bridge getTrackingStatusTrackingStatus::firstOrCreate per event → terminal transition via shared ParcelPath::terminalOrderStatus(). Per-order errors are isolated via report() + continue.

UPS vs USPS differences

Dimension UPS USPS
OAuth Separate UPSOAuthClient Inline on bridge
Account number Required Not used (zip-scoped)
Label format PDF or ZPL PDF only
Dim weight Client-side (÷139) Server-side
Event mapping 7 codes (I/D/X/P/M/O/RS) ALERT→EXCEPTION; rest verbatim
Void semantics DELETE cancel → Status.Code=1 POST refund → refundStatus=APPROVED

Extraction rule compliance

No user-specific or environment-specific logic from ParcelPath v9 was carried over. Explicitly NOT ported: email-based UPS URL override, NBNL handling, return label swap, multi-package letter merge, Ground Saver days derivation, Shippo fallback, predefined USPS package types, international flags, per-user rate-tier branching. Each omission documented in the relevant commit message.


Combined test results

Tests:    1 deprecated, 252 passed (559 assertions)
Duration: 1.92s

The 1 deprecated is a pre-existing PHP 8.2 optional-parameter warning on IntegratedVendor.php:14 — not introduced by this work.

Area Tests
ParcelPath bridge (Phase 1) ~84
UPS bridge + OAuth + enum + tracking ~82
USPS bridge + enum ~49
IntegratedVendor resolver 13
Registry entries (PP + UPS + USPS) ~28
Model + schema 4

Phase 1 smoke test: 18/18 green (run after Phase 1 commits, before Phase 2 work began).


How to test locally

Prerequisites

Docker Desktop up. UPS CIE sandbox + USPS TEM sandbox credentials for runtime validation (not needed for Pest).

Bind-mount setup (one-time)

In fleetbase/docker-compose.override.yml:

services:
  application:
    volumes:
      - ../fleetops/server:/fleetbase/api/vendor/fleetbase/fleetops/server
  queue:
    volumes:
      - ../fleetops/server:/fleetbase/api/vendor/fleetbase/fleetops/server
  scheduler:
    volumes:
      - ../fleetops/server:/fleetbase/api/vendor/fleetbase/fleetops/server

Mount only ../fleetops/server — mounting the full fleetops directory includes server_vendor/ (~58k files) which wedges Docker Desktop.

Run migrations

docker compose exec application php artisan migrate \
  --path=/fleetbase/api/vendor/fleetbase/fleetops/server/migrations \
  --realpath --force

--realpath is required with absolute paths — without it Laravel silently finds nothing.

Run Pest

docker run --rm -v ~/fleetbase-project/fleetops:/app -w /app \
  fleetbase/fleetbase-api:latest \
  sh -c "ln -sf server_vendor vendor && ./server_vendor/bin/pest"

Smoke test

cd ~/fleetbase-project && node scripts/smoke-test.mjs

Reviewer guidance

Phase 1 surfaces

  • ParcelPath onboarding panel renders correctly
  • Rate comparison UI displays ParcelPath quotes with carrier logo + ETA + source label
  • LabelController returns carrier labels from carrier-labels/ folder
  • PollParcelPathTrackingJob creates TrackingStatus rows and transitions Order status

Phase 2 surfaces

  • UPS + USPS appear in the Integrated Vendors "New" provider picker
  • UPS form shows client_id / client_secret / account_number credential fields
  • USPS form shows client_id / client_secret only (no account_number, no label_format)
  • Shipper Client selector renders on IV form, allows null (catch-all), persists shipper_client_uuid
  • Auto-resolve routes through correct credential based on order customer Vendor
  • PollUPSTrackingJob + PollUSPSTrackingJob dispatch correctly and create TrackingStatus rows

What's deferred (Phase 3)

# Task
22 HandleCarrierTrackingStatusCreated listener (SocketCluster real-time)
23 Hybrid multi-facilitator rating (comma-separated facilitators)
24 Hybrid rate comparison UI (cross-source price/speed sort)
25 Batch shipping workflow (CSV + bulk label purchase)
26 ParcelWebhookController (push tracking endpoint)
27 TMS-side Shipsurance integration
28 End-to-end validation + docs

Close-out documents

  • Phase 1: docs/superpowers/2026-04-08-parcelpath-phase-1-closeout.md
  • Phase 2: docs/superpowers/2026-04-09-parcelpath-phase-2-closeout.md
  • Implementation plan: docs/superpowers/plans/2026-04-07-parcelpath-tms-integration.md
  • Phase 2 execution package: docs/superpowers/phase-2-execution.md

🤖 Generated with Claude Code

local and others added 23 commits April 6, 2026 13:15
Mirrors LalamoveServiceType shape (Collection of typed instances with
dynamic property hydration, __get/__call, static all()/find()).
Provides 13 PP_* service keys covering UPS Ground / Ground Saver / 3
Day Select / 2nd Day Air / 2nd Day Air A.M. / Next Day Air / Next Day
Air Early / Next Day Air Saver and USPS Priority / Priority Express /
Ground Advantage / First Class / Media Mail, each annotated with
carrier and pp_v9 service token for routing through the ParcelPath v9
backend.

#[\AllowDynamicProperties] is added to silence PHP 8.2's dynamic
property deprecation while keeping the Lalamove-compatible hydration
pattern. LalamoveServiceType has not yet been migrated to the same
attribute — doing that is out of scope for this change.

Tests (Pest): 9 passed, 26 assertions.
Adds a nullable carrier_tracking_number VARCHAR(100) to tracking_numbers
with an index so webhook and poll handlers can look up the owning
TrackingNumber by the carrier's identifier (1Z... for UPS, 9400... for
USPS, or the ParcelPath-normalized identifier) without querying Order
meta JSON. The indexed lookup path is required by the poll jobs added
in Task 9 and the webhook controller in Task 26.
Appends the parcelpath entry to IntegratedVendors::\$supported with the
single api_key credential, carrier_filter/label_format/insurance_default
/markup option params, and the credentials.api_key -> apiKey bridge
param mapping. iso2cc_bridge is null because ParcelPath is US-only and
no market selector is needed (ResolvedIntegratedVendor::getCountries
already guards against null).

Also introduces a minimal ParcelPath bridge class so ParcelPath::class
resolves and the autoloader can find the namespace. The rating, label,
tracking, and void methods land in Tasks 2-5 on this branch; the stub
exists only so the registry entry wires through cleanly.

Tests (Pest): 8 new registry tests covering core fields, credential
params, option params, bridgeParams, option value lists, and the empty
callbacks array. Full suite: 17 passed.
Expands the Task 6 stub into the full bridge skeleton following the
Lalamove pattern: private host/sandboxHost/namespace/isSandbox fields,
Guzzle\Client instance, chainable setRequestId / setOptions /
setIntegratedVendor setters, buildRequestUrl() path composer, and
private request() / get() / post() / delete() helpers that attach
the Bearer token, Accept: application/json, Content-Type: application/json,
and (when set) X-Request-Id headers.

The constructor accepts an optional Guzzle HandlerStack so tests can
inject a MockHandler without monkey-patching. Production code always
builds its own stack.

Rating, label, tracking, and void methods land in Tasks 3-5.

Tests (Pest): 12 new bridge tests covering constructor / URL / setter
chains / HTTP verb routing / header propagation / non-2xx handling,
using Guzzle MockHandler + Middleware::history. Full suite: 29 passed.
Implements ParcelPath::getQuoteFromPayload using the pure/impure split:

Pure (static, unit-testable without Laravel bootstrap):
  - placeToAddress(Place-like $p): Place -> ParcelPath address shape
  - entitiesToParcels(iterable): Entity -> parcels[] (filters non-parcels,
    propagates package_template from meta)
  - buildRatesRequest(shipFrom, shipTo, parcels, carrierFilter):
    final request body
  - normalizeRatesResponse(array): raw rates[] -> rows ready for
    ServiceQuote::create. Converts dollars to integer cents, handles
    sub-cent rounding, derives insurance_cost cents from insurance_cost
    dollars, skips rows missing `amount`, defaults currency to USD.

Impure wrapper:
  - getQuoteFromPayload(Payload, ?serviceType, ?scheduledAt,
    ?isRouteOptimized): composes the pure helpers, POSTs via the Guzzle
    client, iterates normalized rows creating ServiceQuote +
    ServiceQuoteItem records. company_uuid and carrier_filter are read
    from the bound IntegratedVendor. Returns array of created quotes.

Tests (Pest): 18 new unit tests targeting the pure halves — Place
mapping, parcel mapping (including type filter + template
propagation), request assembly, and response normalization (cents
conversion, rounding, insurance, skipping invalid rows). Full suite:
47 passed.

Impure wrapper is exercised at runtime via the smoke test path; no
ORM tests here (matches Lalamove's pattern and avoids dragging in
orchestra/testbench).
Pure: buildLabelPurchaseRequest, normalizeLabelResponse.
Impure: createOrderFromServiceQuote composes them with the Guzzle
client, writes the label binary to Storage as a File record under
carrier-labels/, and stores the ParcelPath shipment id / tracking
number / insurance payload on Order.meta.integrated_vendor_order.

Tests (Pest): 15 new unit tests covering request build (rate_id
required, label_format uppercase + PDF default) and response
normalization (base64 decode, mime derivation, insurance pass-
through, missing-field errors). Full suite: 62 passed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pure: normalizeTrackingResponse (uppercase status/carrier/codes,
preserve event order, defensive defaults) and normalizeVoidResponse
(accepts \`voided: true\` and case-insensitive 'voided'/'cancelled'
status).
Impure: getTrackingStatus calls GET /v1/tracking/{n}; voidShipment
calls DELETE /v1/shipments/{id}. Both compose the pure normalizer.

Tests (Pest): 14 new unit tests. Full suite: 76 passed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extends LabelController::getLabel to check for a File record in the
carrier-labels folder on the resolved subject before rendering the
internal Blade label. When a carrier label exists (ParcelPath / UPS /
USPS label binaries stamped by the bridge layer), stream it from the
Storage disk. Honors ?format=base64 by returning the encoded bytes in
a JSON response; otherwise streams the raw file.

Backward compatible: orders without carrier labels still get the
internal Blade label as before. No Pest test — the controller edit
is exercised via the smoke test path; the label-persisting code that
creates the File record already has unit coverage via the ParcelPath
label normalizer tests.
Adds PollParcelPathTrackingJob, a queueable scheduled every 15 minutes
via the existing scheduleCommands closure in FleetOpsServiceProvider.
Queries active orders with a parcelpath_shipment_id, asks the
ParcelPath bridge for the latest tracking state, firstOrCreate()s one
TrackingStatus row per new event, and transitions the Order status
when the normalized tracking code is terminal.

Pure: ParcelPath::terminalOrderStatus maps a normalized tracking code
(DELIVERED / RETURN_TO_SENDER / RETURNED / ...) to the Fleetbase
Order status to transition into (completed / returned / null).
Case-insensitive.

Per-order failures (HTTP, vendor misconfig, missing tracking number)
are reported via report() but do not abort the batch — the next poll
cycle will pick them up.

Impure job.handle() is untested here; it composes the Eloquent layer
and runs under the Laravel scheduler. Pure mapping has unit tests;
the tracking normalizer the job depends on is already covered by the
Task 5 tests.

Tests (Pest): 9 new (terminalOrderStatus). Full suite: 85 passed
(173 assertions).
Adds <CarrierOnboardingPanel /> — a Glimmer component that nudges
operators toward ParcelPath as the recommended Phase 1 entry point
for small-parcel shipping while keeping the direct UPS and USPS
bridges one click away. Layout: a featured ParcelPath card with the
"Recommended" badge, plus a collapsible <details> block exposing
direct UPS Direct and USPS Direct cards plus a hybrid-mode tip.

Each Connect button calls integratedVendorActions.create({provider})
when that action exists, falling back to a router transition into
the existing IntegratedVendor new-record flow with the provider as a
query param. No new routes; this composes the form already rendered
by addon/components/integrated-vendor/form.hbs from the registry.

en-us translations added for the panel copy. Other locales fall
through to the en-us defaults (matching the existing fleetops
i18n pattern).

The classic addon/app component pair is in place
(addon/components/carrier-onboarding-panel.hbs+.js plus the
app/components re-export shim) so consumers can mount the panel as
<CarrierOnboardingPanel /> from any host route.
Adapts the existing order form rate selector
(addon/components/order/form/service-rate.hbs +.js) so quotes from an
IntegratedVendor facilitator (ParcelPath / UPS Direct / USPS Direct)
render with carrier-aware metadata instead of falling through to the
generic public_id label.

Component:
  - Adds isIntegratedVendorFacilitator getter (delegates to
    @resource.facilitator.isIntegratedVendor).
  - queryServiceRates now forks: when the facilitator is integrated
    vendor, skip the local ServiceRate query and call getServiceQuotes
    directly with a null serviceRate, which the existing service-rate-
    actions service already handles by passing
    facilitator=public_id to /v1/service-quotes.
  - Refresh button stays enabled in the integrated-vendor path even
    without a selected ServiceRate.

Template:
  - When serviceQuote.meta.carrier is present, render the carrier
    logo (/images/integrated-vendors/{carrier}.png), the service token
    headline, the carrier name, the estimated days (when present), and
    a "via {facilitator}" source label. Falls back to the existing
    public_id rendering when meta.carrier is absent (preserves backward
    compatibility for non-parcel facilitators).
  - The right-aligned badge shows the carrier when present (otherwise
    request_id).
  - data-test- attributes added on the carrier logo, service token,
    ETA, and source label so future component tests can target them.

Translations:
  - order.fields.estimated-days now takes a {days} count
  - order.fields.via-facilitator takes a {facilitator} name

The existing service-rate fetch path is unchanged for non-integrated
vendors. No backend changes — service-rate-actions already passed
facilitator=public_id when present, but the template never used it.
Adds /vendor to .gitignore. This is a local symlink (vendor ->
server_vendor) created on each one-off Pest docker run to work around
Pest's bin script hardcoding the literal `vendor/` path while fleetops
uses a non-standard composer vendor-dir of `server_vendor`. Not real
source — keeps `git status` clean and prevents accidental staging.
Mirrors LalamoveServiceType / ParcelPathServiceType shape (Collection
of typed instances with dynamic property hydration, __get/__call,
static all()/find()) for the 8 direct-carrier UPS service levels used
by the Phase 2 UPS bridge: Ground (03), Ground Saver (93), 3 Day
Select (12), 2nd Day Air (02), 2nd Day Air A.M. (59), Next Day Air
(01), Next Day Air Early (14), Next Day Air Saver (13).

Each entry carries a numeric service_code matching UPS's Service.Code
identifier used in Rate Shop / Ship API requests. carrier='UPS' is
set on every row so the static helpers don't need to guess provider.

#[\AllowDynamicProperties] on the class silences the PHP 8.2 dynamic
property deprecation while keeping the Lalamove-compatible hydration
pattern.

Tests (Pest): 9 passed, 60 assertions.
Manages UPS's OAuth 2.0 client-credentials flow against the
/security/v1/oauth/token endpoint. Tokens are cached per-clientId
under the key ups_oauth_token_{clientId} with a TTL of
(expires_in - 60) seconds, clamped to a 60-second minimum, so
refresh happens well before carrier-side expiry and multi-tenant
brokers cannot collide on each other's tokens.

Cache strategy:
  - The class accepts an injectable \ArrayAccess cache in the
    constructor. In tests a plain \ArrayObject is passed so the
    unit tests run under Pest without booting Laravel.
  - Production wires through UPSOAuthClient::productionCache(), a
    tiny anonymous class that adapts Laravel's Cache facade to
    \ArrayAccess. The facade is only referenced inside that
    adapter method body — never at class-load time — so the
    Pest-without-Laravel-bootstrap property is preserved.
  - Deliberate choice: we stay at \ArrayAccess instead of a full
    PSR-16 CacheInterface because PSR-16 would require a bound
    cache implementation at import time, which breaks isolation.

Sandbox vs production routes to wwwcie.ups.com vs
onlinetools.ups.com. HTTP Basic auth. Non-2xx or missing
access_token throws RuntimeException (caller handles retry).

Port note: adapted from ParcelPath v9 UPSDAPService::getOAuthToken
with the email-based production-URL override stripped per the
Phase 2 porting rules. Only the generic sandbox/production host
selection is preserved — no user-specific branch paths, no
session-scoped routing.

Tests (Pest): 12 passed, 20 assertions. Full suite: 106 passed.
Adds shipper_client_uuid to integrated_vendors so a broker can scope
a single carrier credential record to a specific shipper client
(modeled as a fleetops Vendor). When NULL, the record acts as the
broker-level catch-all for the given provider — matching the
resolver fallback behavior that ServiceQuoteController auto-resolve
will ship in Task 19.

Schema:
  - uuid column, nullable, after company_uuid
  - FK to vendors.uuid ON DELETE SET NULL
  - composite index (company_uuid, provider, shipper_client_uuid)
    named iv_company_provider_shipper_idx for the two-stage lookup
    in the resolver: first by (provider, shipper_client_uuid=X),
    then fallback to (provider, shipper_client_uuid IS NULL)

IntegratedVendor model:
  - shipper_client_uuid added to $fillable
  - new shipperClient() belongsTo relationship targeting
    Fleetbase\FleetOps\Models\Vendor on uuid

Tests (Pest): 4 reflection-based assertions (4 functional, 1
deprecation noise from IntegratedVendor.php:14 which carries a
pre-existing PHP 8.2 optional-parameter warning outside this PR's
scope — not introduced here, not modified here).

Full suite: 109 passed. Migration executed live via
  php artisan migrate --path=/fleetbase/api/vendor/fleetbase/fleetops/server/migrations --realpath --force
Schema verified post-migration: column present, index spans 3
columns, FK present.
Introduces the UPS bridge class following the Phase 1 ParcelPath
pattern: private host/sandbox fields, Guzzle Client with injectable
HandlerStack, chainable setRequestId/setOptions/setIntegratedVendor,
pure static helpers, thin impure instance wrapper.

Pure (static, unit-testable without Laravel bootstrap):
  - dimensionalWeight(L,W,H,divisor=139): float
  - billableWeight(actualLb,dimLb): float  [max() of the two]
  - placeToUpsAddress(place): array        [UPS Address shape]
  - entityToUpsPackage(entity): array      [PackagingType/Dimensions/
                                            PackageWeight with dim
                                            weight baked in]
  - buildRateShopRequest(shipFrom, shipTo, packages, accountNumber,
      ?serviceCode): array                 [forks Shop vs Rate by
                                            presence of serviceCode]
  - normalizeRateShopResponse(array, markupType, markupValue): array
      - prefers NegotiatedRateCharges.TotalCharge over TotalCharges
        (AGP selection) when present
      - handles single RatedShipment returned as object OR array
      - cents conversion with sub-cent rounding
      - flat / percent markup on top of carrier amount
      - resolves service description via UPSServiceType::find
      - returns rows ready for ServiceQuote::create

Impure wrapper:
  - getQuoteFromPayload(Payload, ?serviceType, ...) composes pure
    helpers + UPSOAuthClient + Guzzle POST to
    /api/rating/v2403/rate/{Shop|Rate}, writes ServiceQuote +
    ServiceQuoteItem rows under the bound IntegratedVendor's
    company_uuid with the vendor's markup options applied.

Constructor accepts an injectable Guzzle HandlerStack and an
injectable UPSOAuthClient so Pest tests can stub both without
touching real UPS endpoints.

## Extraction rule compliance

Per the Phase 2 porting rules, NO user-specific or
environment-specific logic from ParcelPath v9 is carried over. Only
generic UPS Rating API semantics are implemented. Specifically NOT
carried over (deferred to future PRs if needed):
  - NBNL (Non-Barcoded Non-Letter) barcode-to-PDF handling
  - Multi-package letter merge (mergeUPSRateResponse)
  - Ground Saver service 93 estimated-days derivation
  - Any email-based UPS URL override or session-scoped routing

PP v9 source was not available in this workspace during this port;
the bridge was built directly against UPS's public Rating API
v2403 documentation, which aligns with the extraction rule.

Tests (Pest): 22 passed covering dim weight math, billable weight
selection, place/entity mapping, Shop-vs-Rate request assembly,
multi-package support, response normalization (AGP preference,
object/array handling, markup arithmetic, service resolution,
sub-cent rounding, empty-response defense). Full suite: 131 passed.
Extends the UPS bridge with Shipping API v2409 and Void API support
following the same pure/impure architecture as Task 14.

Pure static helpers (unit-tested via Pest without Laravel bootstrap):
  - signatureConfirmationCode(?string): ?int
      'standard' -> 2, 'adult' -> 3, anything else -> null
      Case-insensitive. null opts out of DeliveryConfirmation entirely.
  - buildShipRequest(shipperName, shipFrom, shipTo, packages,
      accountNumber, serviceCode, labelFormat='PDF',
      ?orderPublicId=null, ?signaturePreference=null): array
      Returns the full ShipmentRequest body with:
        - BillShipper payment at the supplied account number
        - RequestOption=nonvalidate (UPS still validates, just no
          explicit AddressValidation fail-hard)
        - Service.Code set to the requested UPS service
        - LabelSpecification.LabelImageFormat.Code uppercased
          (PDF default, ZPL opt-in, no validation of image format)
        - ReferenceNumber code 00 populated with the FleetOps order
          public_id on every package when provided
        - DeliveryConfirmation.DCISType populated when signature is
          requested, omitted otherwise
        - Shipment.Description carrying the order public_id for
          carrier-side visibility
  - normalizeShipResponse(array): array
      Returns {tracking_number, shipment_id, label_binary,
      label_format, label_mime}. Decodes base64 GraphicImage.
      Handles the UPS quirk where a single PackageResults comes back
      as an object not an array. Throws RuntimeException on malformed
      responses so the impure wrapper can surface vendor errors.
  - normalizeVoidResponse(array): bool
      true when VoidShipmentResponse.SummaryResult.Status.Code=='1'
      or Status.Description=='Success' (case-insensitive). false
      otherwise.

Impure instance wrappers (untested here; smoke-test gated at Task 17):
  - createOrderFromServiceQuote(ServiceQuote, Order): array
      Composes buildShipRequest from the order's pickup/dropoff/
      parcel entities + the ServiceQuote's stashed service_code +
      the IntegratedVendor's label_format option + the order's
      optional signature_confirmation meta, POSTs to
      /api/shipments/v2409/ship, normalizes the response, writes the
      label binary under carrier-labels/ as a File record, and
      stores shipmentIdentificationNumber + tracking_number +
      carrier='UPS' + service_code on Order.meta.integrated_vendor_order.
  - voidShipment(string $shipmentIdentificationNumber): bool
      Calls DELETE /api/shipments/v1/void/cancel/{id}, returns
      normalizeVoidResponse().

## Extraction rule compliance

Per the Phase 2 porting rules, NO user-specific or
environment-specific logic from ParcelPath v9 is carried over.

Explicitly NOT carried over (call-sites documented inline where
relevant):
  - Email-based UPS URL override in voidShipment. PP v9's void path
    switched hosts depending on $user->email. That branch is
    stripped entirely; only the generic sandbox/production host
    selection via UPSOAuthClient + $this->baseUrl() is used.
  - NBNL (Non-Barcoded Non-Letter) barcode-to-PDF post-processing.
  - Return label auto-swap when shipFrom and shipTo are inverted.
  - Multi-package letter merge (mergeUPSRateResponse).
  - Ground Saver service 93 estimated-days derivation.

PP v9 source was not available in this workspace; the bridge was
built directly against UPS Shipping API v2409 and Void API v1
documentation, which aligns with the extraction rule.

Tests (Pest): 24 new passing covering signature code mapping,
ShipmentRequest assembly (Shipper/ShipTo/ShipFrom, BillShipper
payment, Service.Code, label format uppercasing, reference number
injection, DeliveryConfirmation inclusion/omission, multi-package),
response normalization (tracking number extraction, base64 decode,
mime derivation for PDF/ZPL, single-package-as-object quirk,
RuntimeException on malformed responses), and void response
normalization (Code='1', Description='Success', failure modes,
empty-response defense). Full suite: 155 passed.
Appends the ups entry to IntegratedVendors::\$supported so the UPS
direct bridge class (committed in Tasks 14 + 15) is now
constructible through the existing IntegratedVendor::api()
resolution machinery. USPS is explicitly NOT registered yet —
Task 16 (USPS bridge + enum) must land first.

Registry shape (matches spec §3.2 and the Phase 1 ParcelPath
entry's style exactly):
  - code: 'ups'
  - host: https://onlinetools.ups.com/
  - sandbox: https://wwwcie.ups.com/
  - namespace: 'api'
  - bridge: UPS::class
  - svc_bridge: UPSServiceType::class
  - iso2cc_bridge: null (UPS is a US-first carrier; no market selector)
  - credentialParams: client_id, client_secret, account_number
  - optionParams: label_format (PDF/ZPL), markup_type (flat/percent),
    markup_amount, client_label
  - bridgeParams: credentials.client_id -> clientId,
    credentials.client_secret -> clientSecret,
    credentials.account_number -> accountNumber,
    sandbox -> sandbox
  - callbacks: [] (UPS does not expose push webhooks)

No UI changes. No controller changes. No runtime smoke test. The
existing dynamic integrated-vendor/form.hbs will render the UPS
credential + option fields automatically from the registry contract
— same behavior as ParcelPath got in Phase 1. Nothing in this
commit triggers a UPS API call; the bridge is merely reachable via
the dependency-injection chain now.

Tests (Pest): 8 new registry assertions covering core shape,
credential params, option params, bridge params, option value
lists, empty callbacks, and a regression guard on the parcelpath
entry. Full suite: 163 passed.
Adds the USPS direct bridge for Mode B, parallel to Phase 2's UPS
bridge but following USPS Web Tools v3 semantics.

USPSServiceType enum: 5 entries (PRIORITY, PRIORITY_EXPRESS,
GROUND_ADVANTAGE, FIRST_CLASS, MEDIA_MAIL) with mail_class values
matching the USPS v3 API identifiers. Mirrors UPSServiceType shape
(Collection of typed instances, dynamic hydration, magic getters,
#[\AllowDynamicProperties]).

USPS bridge class:
  - Same constructor shape as UPS.php but with inline OAuth instead
    of a separate client class — USPS v3's oauth2/v3/token flow is
    simple enough for a private getAccessToken() method on the
    bridge. Same HTTP Basic + grant_type=client_credentials pattern,
    same cache-key-by-clientId scoping (usps_oauth_token_{clientId}),
    same (expires_in - 60)s TTL clamped to >=60s.
  - Sandbox routes to apis-tem.usps.com, production to api.usps.com.
  - Injectable Guzzle HandlerStack and \ArrayAccess cache so Pest
    runs without Laravel bootstrap.

Pure static helpers (unit-tested):
  - placeToUspsAddress: [streetAddress, city, state, ZIPCode]
  - entityToUspsParcel: [length, width, height, weight] (no dim
    weight math — USPS v3 computes billable weight server-side)
  - buildRatesRequest: POST /prices/v3/base-rates/search body
  - normalizeRatesResponse: rates[].price -> cents, flat/percent
    markup, description via USPSServiceType, skips rows missing
    a price, defaults currency to USD
  - buildLabelRequest: POST /labels/v3/label body. imageType is
    ALWAYS 'PDF' — USPS v3 does not issue ZPL labels.
  - normalizeLabelResponse: {tracking_number, label_binary,
    label_format='PDF', label_mime='application/pdf'}. PDF
    enforced regardless of what labelMetadata reports. Throws
    RuntimeException on missing trackingNumber/labelImage.
  - normalizeTrackingResponse: USPS v3 eventType -> Fleetbase
    TrackingStatus code. ALERT -> EXCEPTION per spec §6.2; other
    known codes pass through verbatim. Preserves event order;
    derives final status from last trackingEvents[] entry.
  - uspsEventTypeToFleetbaseCode: extracted as a public pure
    helper for the Task 21 PollUSPSTrackingJob code mapping.
  - normalizeVoidResponse: true when refundStatus='APPROVED' (case-
    insensitive). USPS v3 models label voids as refunds.

Thin impure wrappers (smoke-test gated):
  - getQuoteFromPayload: rates the first parcel only (multi-parcel
    is Phase 3 Task 25 batch shipping work), writes
    ServiceQuote + ServiceQuoteItem via bound IntegratedVendor's
    company_uuid + markup options.
  - createOrderFromServiceQuote: composes buildLabelRequest, POSTs
    to /labels/v3/label, writes File record under carrier-labels/
    named usps_label_{tracking}.pdf, stores carrier='USPS' +
    tracking_number + mail_class on Order.meta.integrated_vendor_order.
  - getTrackingStatus: GET /tracking/v3/tracking/{trackingNumber}
  - voidShipment: POST /labels/v3/refund (first-smoke-test may
    reveal a different path; the helpers are path-agnostic).

## USPS-specific quirks

  - v3 is one-parcel-per-request for both rates and labels; multi-
    parcel orders are Phase 3 Task 25
  - PDF-only; the label_format option will NOT appear on the Task 17
    USPS registry entry (contrast with UPS)
  - No account number; credential tuple is clientId + clientSecret
  - No client-side dim weight math; server computes billable weight
  - Tracking events are chronological (first = oldest)

## Extraction rule compliance

Per the Phase 2 porting rules, NO user-specific or
environment-specific logic from ParcelPath v9 / EasyPostService is
carried over. Explicitly NOT carried over: Shippo fallback branch,
predefined USPS package type detection (flat rate envelope / box
sizes), international shipping flag, any email-based URL overrides
or per-user rate-tier branching.

PP v9 source was not available in this workspace; the bridge was
built directly against USPS Web Tools v3 documentation.

Tests (Pest): 49 new passing — 11 enum + 20 rates/oauth + 18 label/
tracking/void. Full suite: 212 passed (484 assertions).
Appends the usps entry to IntegratedVendors::\$supported, completing
the Task 17 registry wiring started when the UPS half landed in
8c7d495. USPS bridge class (committed in the Task 16 change on
this branch) is now constructible through IntegratedVendor::api().

## Registry shape (mirrors UPS with two deliberate differences)

  - code: 'usps'
  - host: https://api.usps.com/
  - sandbox: https://apis-tem.usps.com/
  - namespace: 'v3'
  - bridge: USPS::class
  - svc_bridge: USPSServiceType::class
  - iso2cc_bridge: null
  - credentialParams: client_id, client_secret  (NO account_number)
  - optionParams: markup_type, markup_amount, client_label
                  (NO label_format — USPS v3 is PDF-only; enforced
                  in USPS::normalizeLabelResponse regardless of what
                  the carrier response reports)
  - bridgeParams: credentials.client_id -> clientId,
                  credentials.client_secret -> clientSecret,
                  sandbox -> sandbox
                  (NO accountNumber constructor arg)
  - callbacks: [] (no webhook registration)

## Differences from the UPS entry, called out explicitly

  - No account_number credential field. USPS v3 rates and labels
    are zip-code and credential scoped — there is no shipper
    account number. UPS's constructor takes three credential
    fields; USPS's takes two.
  - No label_format option param. UPS supports PDF and ZPL (opt-in
    for thermal printer users); USPS v3 does NOT issue ZPL labels
    at all, so a label_format option would be misleading dead
    configuration surface.

Everything else is identical to the UPS registry entry (markup
options, bridgeParams mapping, empty callbacks list).

## Logo asset tracking

Adds server/src/Integrations/README-logos.md documenting the expected
integrated vendor logo asset paths and their sourcing requirements.
The actual usps.png (and ups.png, and parcelpath.png from Phase 1)
are still pending — UPS and USPS have strict brand usage guidelines
that require sourcing from official carrier brand asset pages and
legal review before merge. Until the PNGs are added the IV form will
render broken-image icons for these three providers; the registry
logic is not blocked. Documented so reviewers catch it during the
Phase 2 PR.

No UI changes. No controller changes. No runtime smoke test.
Nothing in this commit triggers a USPS API call — the bridge is
merely reachable via the dependency-injection chain now, same as
the UPS half in 8c7d495.

Tests (Pest): 12 new USPS registry assertions covering core shape,
credential param restrictions (no account_number), option param
restrictions (no label_format), bridge param mapping, empty
callbacks, and regression guards on parcelpath/ups/lalamove still
being present. Full suite: 224 passed (509 assertions).

Task 17 is now complete: both UPS and USPS are registered.
Adds the IntegratedVendorResolver orchestration layer and wires it
into ServiceQuoteController::queryRecord so broker deployments can
route quote requests through the right carrier credential
automatically based on the order's customer Vendor.

## Architecture: pure + impure split

IntegratedVendorResolver is a new Support class with two layers
matching the Phase 1 / Phase 2 bridge convention:

  - chooseVendorUuids(candidates, shipperClientUuid, providerFilter)
    Pure static function. Takes an array of candidate rows with
    at least {uuid, provider, shipper_client_uuid} and returns the
    list of vendor UUIDs to route through. Deterministic, fully
    unit-testable under Pest without booting Laravel.

  - resolveForQuoteRequest(companyUuid, order, providerFilter)
    Impure wrapper. Queries IntegratedVendor via Eloquent, runs the
    pure chooser, hydrates back to models ordered to match the pure
    result. Used by the controller.

## Resolution rule (asserted by 13 new pure tests)

For each distinct provider in the candidate list:
  1. If shipperClientUuid is non-null AND an exact-match candidate
     exists, pick it.
  2. Otherwise, if a catch-all candidate exists
     (shipper_client_uuid IS NULL), pick it.
  3. Otherwise, silently drop the provider. NEVER route through a
     mismatched shipper_client_uuid — that would bill the wrong
     account, which is the entire reason this rule exists. Breaking
     this invariant is the single biggest risk a broker-scale
     deployment faces.

When providerFilter is non-empty, only candidates whose provider
is in the filter are considered.

## ServiceQuoteController::queryRecord changes

  - Keeps the existing explicit facilitator=integrated_vendor_xxx
    path fully intact — passing that URL param still bypasses the
    auto-resolve block entirely. Backward compatible.
  - Adds a new block after the explicit-facilitator check: when the
    request carries an `order` public_id and/or a `providers` filter,
    the controller
      1. loads the Order (with customer) if order is set
      2. parses providers from a comma-separated string or an array
      3. calls IntegratedVendorResolver::resolveForQuoteRequest
      4. calls ->api()->getQuoteFromPayload on every resolved vendor
      5. aggregates all returned ServiceQuote rows into one response
  - Per-vendor failures are reported via report() but do NOT abort
    the batch — a single misconfigured carrier credential should not
    block rate discovery for the others. Only vendors that made it
    past the resolver's consistency filter ever enter the loop, so
    exceptions here are upstream carrier errors worth surfacing to
    observability rather than flipping the whole request to a 400.
  - Falls through to the existing ServiceRate flow when the
    auto-resolve block finds nothing. Zero regression on workflows
    that don't use IntegratedVendors.

## Request contract (new optional inputs)

  - `order` (string, optional) — the Order public_id this quote
    request is for. When present, the controller loads the order
    to extract the shipper client Vendor from its customer_uuid
    (only when customer_type is vendor).
  - `providers` (string or array, optional) — comma-separated or
    array list of provider codes (e.g. 'ups,usps') restricting the
    auto-resolve to those carriers. Empty = no filter.

Neither input is required. Both are opt-in. Existing callers that
pass neither `order` nor `providers` continue to get the original
ServiceRate behavior when no explicit facilitator is set.

## Not in this commit

  - No UI changes. The Ember order form will need to start passing
    `order=public_id` on quote requests to exercise the new path;
    that wiring is Phase 2 Task 20 (Shipper Client selector) + a
    small adjacent tweak to serviceRateActions.getServiceQuotes.
  - No live carrier API calls. The pure chooser is fully unit-tested
    and the impure wrapper is exercised at runtime via smoke test
    once the Task 20 UI wiring lands.
  - No controller changes to the Api/v1 variant — Task 19's scope is
    the Internal/v1 ServiceQuoteController that the Ember console
    hits. The public Api/v1 controller can adopt the same resolver
    in a follow-up.

## Edge cases the resolver explicitly handles (asserted by tests)

  - Exact match vs fallback — covered
  - Client-specific match even when catch-all comes first in the
    candidate list (ordering is not load-bearing) — covered
  - Multi-provider resolution — one row per distinct provider
  - Mixed client-specific + catch-all within the same candidate set
  - Silent provider drop when no candidates exist at all
  - Silent provider drop when neither client-specific nor catch-all
    exists for the requested shipper
  - Empty candidate list returns empty
  - Null shipper client uses only whereNull candidates (non-broker
    / direct customer path)
  - Provider filter restriction
  - Empty provider filter treated as no filter
  - Null provider filter treated as no filter
  - Determinism across candidate ordering

Tests (Pest): 13 new pure-chooser tests, 23 assertions. Full suite:
237 passed (532 assertions). No new runtime HTTP calls — the
resolver operates on whatever IntegratedVendor rows the company
already has configured.
@roncodes
Copy link
Copy Markdown
Member

This is good, and we're open to adding ParcelPath as an integration as a provider for UPS as an integrated facilitator. I will continue to review, but some of the assumptions made by the AI agent is wrong, migrations are discoverable etc.

Also there is a loose component added carrier-onboarding-panel but doesn't seem to be used anywhere, what is the purpose?

TLemmAI added 2 commits April 9, 2026 21:24
Adds two scheduled tracking poll jobs (PollUPSTrackingJob,
PollUSPSTrackingJob) mirroring PollParcelPathTrackingJob from
Phase 1 Task 9 in structure, error isolation, and idempotency
semantics. Adds the UPS tracking normalizer and event code mapper
that were missing from UPS.php (Tasks 14+15 covered rating/ship/void
only).

## New pure helpers on UPS.php

  - upsActivityCodeToFleetbaseCode(string): string
    Maps UPS Tracking API activity type codes per spec §6.2:
      I -> IN_TRANSIT, D -> DELIVERED, X -> EXCEPTION,
      P -> PICKED_UP, M -> MANIFESTED, O -> OUT_FOR_DELIVERY,
      RS -> RETURN_TO_SENDER
    Case-insensitive. Unknown codes pass through uppercased.
    Symmetric with USPS::uspsEventTypeToFleetbaseCode() which
    already ships in Task 16.

  - normalizeTrackingResponse(array): array
    Parses trackResponse.shipment[0].package[0].activity[],
    maps each entry through the code mapper, builds location
    from city + stateProvince, derives timestamp from YYYYMMDD +
    HHMMSS date/time pair (UPS's native format). Handles the UPS
    quirk where a single activity comes back as a direct object
    not an array. Status = last event code.

  - getTrackingStatus(string $trackingNumber): array
    Impure wrapper — GET /api/track/v1/details/{trackingNumber}
    through the OAuth-authenticated Guzzle client, then
    normalizeTrackingResponse.

## New tests

  server/tests/Integrations/UPS/UPSTrackingNormalizerTest.php:
  - 11 pure Pest tests covering the activity code map (all 7 spec
    codes + case-insensitivity + unknown passthrough + empty string)
  - 5 pure Pest tests covering normalizeTrackingResponse (multi-
    event with location, RS -> RETURN_TO_SENDER, missing activity,
    single activity as object, missing location)

  NOTE: Pest was unresponsive via Docker in this session window.
  Tests are written and staged but have not been verified green in
  this commit. First session that can run `docker run --rm ... pest`
  should run the full suite and confirm. The test shapes mirror
  the proven Phase 1 + Phase 2 patterns and the code under test is
  direct copy of the USPS tracking normalizer (already green in
  Task 16) adapted for UPS's different response shape.

## PollUPSTrackingJob

  - Scope: orders in-flight + meta carries
    integrated_vendor_order.shipmentIdentificationNumber
  - Provider filter: facilitator provider = 'ups'
  - Bridge: UPS::getTrackingStatus(trackingNumber)
  - Per event: TrackingStatus::firstOrCreate (idempotent)
  - Terminal: reuses ParcelPath::terminalOrderStatus from Phase 1
    (DELIVERED -> completed, RETURN_TO_SENDER -> returned)
  - Per-order errors: report() + continue (batch isolation)
  - $tries=1, $timeout=300 (same as ParcelPath job)
  - Scheduled everyFifteenMinutes()->withoutOverlapping()

## PollUSPSTrackingJob

  - Scope: orders in-flight + meta carries carrier='USPS' +
    tracking_number
  - Provider filter: facilitator provider = 'usps'
  - Bridge: USPS::getTrackingStatus(trackingNumber) (already
    existed in Task 16)
  - Event mapping: uses USPS::normalizeTrackingResponse which
    calls uspsEventTypeToFleetbaseCode (ALERT -> EXCEPTION;
    everything else verbatim)
  - Otherwise identical to the UPS job (same firstOrCreate,
    same terminal helper, same error isolation, same timeout)
  - Scheduled everyFifteenMinutes()->withoutOverlapping()

## FleetOpsServiceProvider

Both jobs registered in the existing scheduleCommands closure
alongside the ParcelPath job from Phase 1 Task 9. Three tracking
poll jobs now run every 15 minutes — one per carrier provider.

## Carrier-specific quirks

  UPS:
  - Tracking API returns events in
    trackResponse.shipment[0].package[0].activity[]. This is
    triply nested. If the array is absent at any level, the
    normalizer returns UNKNOWN status + empty events (defensive).
  - Date/time are separate fields (YYYYMMDD / HHMMSS), not ISO
    timestamps. The normalizer composes them into YYYY-MM-DDTHH:MM:SS
    for Fleetbase's TrackingStatus.created_at column.
  - Single activity returned as object-not-array: same UPS quirk
    as single RatedShipment in rating. Handled explicitly.
  - UPS tracking number format: 1Z... (18 chars). Phase 2 scoped
    to 1Z tracking only; reference-number tracking (NBNL) is
    deferred.

  USPS:
  - Tracking API returns events in trackingEvents[] (flat, not
    triply nested). Events are chronological (oldest first).
  - ALERT -> EXCEPTION mapping is the only non-identity code
    transform.
  - USPS tracking numbers are 20+ digit numeric strings
    (9400..., 9200..., etc).

## Not in this commit

  - No live carrier API calls
  - No UI changes
  - No credentials required
  - The USPS bridge's getTrackingStatus already existed (Task 16);
    only the job wrapping it and the scheduler registration are new
Adds an optional Shipper Client vendor lookup to the IntegratedVendor
form, completing the broker auto-resolution chain started by the
shipper_client_uuid migration (Task 18) and the ServiceQuoteController
auto-resolve logic (Task 19).

## UI change

A new "Shipper Client" ContentPanel renders between the Options and
Advanced Options panels on the IV form. It contains a <ModelSelect>
bound to the company's Vendor records via @modelName="vendor" —
the same component and pattern already used in:
  - driver/form.hbs (assign vendor to driver)
  - modals/place-assign-vendor.hbs
  - modals/driver-assign-vendor.hbs
  - part/form.hbs

Behavior:
  - Selecting a vendor sets @resource.shipper_client_uuid to the
    vendor's id (UUID). The API serializer persists this to the
    integrated_vendors.shipper_client_uuid column added in Task 18.
  - Clearing the selection (via @allowClear={{true}}) sets the UUID
    to null, making this record the broker-level catch-all for its
    provider.
  - On edit (existing IV record with a non-null shipper_client_uuid),
    the form.js constructor asynchronously resolves the Vendor model
    from the Ember Data store so the <ModelSelect> renders the
    vendor's name rather than showing empty. Uses peekRecord first
    for cache-friendliness, falls back to findRecord.

## form.js changes

  - Injects @service store
  - Adds @Tracked selectedShipperClient (null by default)
  - Adds _loadExistingShipperClient() async helper called from
    constructor — resolves the Vendor when editing an existing IV
  - Adds @action setShipperClient(vendor) — sets uuid on resource
    and updates the tracked property

## Translations (en-us)

  - integrated-vendor.fields.shipper-client: "Shipper Client"
  - integrated-vendor.fields.shipper-client-label:
    "Shipper Client (optional)"
  - integrated-vendor.fields.shipper-client-placeholder:
    "Select a shipper client..."
  - integrated-vendor.fields.shipper-client-help-text: explains the
    scoping behavior and catch-all default

## No backend changes

The PHP model (IntegratedVendor) already has shipper_client_uuid in
$fillable and the shipperClient() relationship method — both landed
in Task 18. The API serializer already exposes the column. No backend
changes are needed for this UI wiring.

## No Ember tests

The existing tests/integration/components/integrated-vendor/form-test.js
is an auto-generated boilerplate placeholder with no meaningful
assertions. Follows the Phase 1 precedent (Tasks 10 + 11 shipped
with no Ember component tests). Manual verification path documented
below.

## Manual verification path

1. Start the stack (docker compose up) + Ember console dev server
2. Navigate to Fleet-Ops → Management → Integrated Vendors → New
3. Pick UPS (or any provider)
4. Scroll to the "Shipper Client" panel — a <ModelSelect> dropdown
   should appear showing all company Vendor records
5. Select a vendor → save → verify shipper_client_uuid is populated
   on the IntegratedVendor record (check via API or tinker)
6. Edit the same record → verify the vendor name appears pre-selected
   in the dropdown
7. Clear the selection → save → verify shipper_client_uuid is null
8. Create a second IV for the same provider with a different vendor
   or no vendor (catch-all) → verify auto-resolve picks the right
   one based on order customer (smoke test)
@TLemmAI TLemmAI changed the title feat(parcelpath): Phase 1 — ParcelPath integration (Mode A default) feat: ParcelPath + direct UPS/USPS integration (Phase 1 + Phase 2) Apr 10, 2026
@TLemmAI
Copy link
Copy Markdown
Author

TLemmAI commented Apr 10, 2026

Code review

Found 2 issues:

  1. Webhook endpoint is unauthenticated by default in production. ParcelWebhookController::verifyWebhookSecret() returns true when no secret is configured, making the endpoint effectively open. An attacker can POST fabricated tracking events to /webhooks/parcel/parcelpath which create real TrackingStatus rows and trigger TrackingStatusObserver to transition Order statuses (e.g., marking orders as "completed" without actual delivery). The secret requires manual env var setup (config('services.parcelpath.webhook_secret')) which is not set in any config file shipped in this PR.

https://github.com/TLemmAI/fleetops/blob/c6f254b06f2f76dd49df6cf7714c76f5ecbafd2a/server/src/Http/Controllers/Api/v1/ParcelWebhookController.php#L143-L153

  1. Batch purchase fails for auto-created orders because BatchShipmentController::purchaseRow reads $sq->meta['facilitator_public_id'] (line 256) to resolve the IntegratedVendor, but none of the three bridge classes (ParcelPath, UPS, USPS) ever set facilitator_public_id in ServiceQuote.meta when creating quotes. The fallback checks $order->facilitator_uuid, but auto-created orders (line 232-237) don't set this field either. Both resolution paths fail, throwing RuntimeException("Cannot resolve IntegratedVendor for label purchase") for the primary CSV-upload batch use case.

https://github.com/TLemmAI/fleetops/blob/c6f254b06f2f76dd49df6cf7714c76f5ecbafd2a/server/src/Http/Controllers/Api/v1/BatchShipmentController.php#L255-L266

Generated with Claude Code

If this code review was useful, please react with 👍. Otherwise, react with 👎.

@TLemmAI
Copy link
Copy Markdown
Author

TLemmAI commented Apr 10, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

The two issues from the prior review (webhook auth default-open, batch vendor resolution failure) are fixed in 923bf5f. Verified: webhook now rejects when unconfigured, all three bridges store facilitator_public_id in ServiceQuote.meta, and batch-created Orders carry facilitator_uuid. 326 tests passing.

Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants