feat: ParcelPath + direct UPS/USPS integration (Phase 1 + Phase 2)#224
feat: ParcelPath + direct UPS/USPS integration (Phase 1 + Phase 2)#224TLemmAI wants to merge 25 commits intofleetbase:mainfrom
Conversation
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.
|
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 |
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)
Code reviewFound 2 issues:
Generated with Claude Code If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Code reviewNo 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 |
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
ParcelPathServiceTypeenum (13 PP_* service keys)b15ea084carrier_tracking_numbermigration ontracking_numbers7e0c819bIntegratedVendors::$supported3c0e5b4f189dfe85POST /v1/rateswith pure builders + normalizerse8f6db1aPOST /v1/labelswith File record write5870f32d52d6aac4LabelControllercarrier-label fallback before internal Blade label6494c48aPollParcelPathTrackingJob(every 15 min, terminal status transitions)75eb7972<CarrierOnboardingPanel />Ember componentf28623daae047558.gitignore: ignore Pest workaroundvendorsymlinkf0cce793Phase 1 architecture
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
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
UPSServiceTypeenum (8 service levels with numeric codes)9602e709UPSOAuthClientwith Redis-cached bearer tokens3ba788c1shipper_client_uuidmigration onintegrated_vendorse40354a01f804f74d521ad7fUSPSServiceType+ USPS v3 bridge (inline OAuth, PDF-only labels)c1a8e849IntegratedVendors::$supported8c7d495b+c7a53570IntegratedVendorResolver+ServiceQuoteControllerauto-resolvee96465ad<ModelSelect>on IntegratedVendor form27a3a47dPollUPSTrackingJob+PollUSPSTrackingJob+ UPS event mapperd8ff2791Phase 2 architecture
Broker auto-resolve (the most architecturally significant addition)
IntegratedVendorResolver::chooseVendorUuidsimplements a three-step resolution rule per provider:shipper_client_uuid IS NULL), use it.Tested with 13 pure unit tests covering every combination. The
ServiceQuoteControllernow 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
getTrackingStatus→TrackingStatus::firstOrCreateper event → terminal transition via sharedParcelPath::terminalOrderStatus(). Per-order errors are isolated viareport()+ continue.UPS vs USPS differences
UPSOAuthClientExtraction 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
The 1 deprecated is a pre-existing PHP 8.2 optional-parameter warning on
IntegratedVendor.php:14— not introduced by this work.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:Mount only
../fleetops/server— mounting the fullfleetopsdirectory includesserver_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--realpathis required with absolute paths — without it Laravel silently finds nothing.Run Pest
Smoke test
Reviewer guidance
Phase 1 surfaces
LabelControllerreturns carrier labels fromcarrier-labels/folderPollParcelPathTrackingJobcreatesTrackingStatusrows and transitions Order statusPhase 2 surfaces
client_id/client_secret/account_numbercredential fieldsclient_id/client_secretonly (noaccount_number, nolabel_format)shipper_client_uuidPollUPSTrackingJob+PollUSPSTrackingJobdispatch correctly and create TrackingStatus rowsWhat's deferred (Phase 3)
HandleCarrierTrackingStatusCreatedlistener (SocketCluster real-time)ParcelWebhookController(push tracking endpoint)Close-out documents
docs/superpowers/2026-04-08-parcelpath-phase-1-closeout.mddocs/superpowers/2026-04-09-parcelpath-phase-2-closeout.mddocs/superpowers/plans/2026-04-07-parcelpath-tms-integration.mddocs/superpowers/phase-2-execution.md🤖 Generated with Claude Code