|
| 1 | +# ParcelPath / UPS / USPS — Small Parcel TMS Integration |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +FleetOps supports three coexisting integration modes for UPS and USPS small-parcel shipping: |
| 6 | + |
| 7 | +| Mode | Description | Carrier credentials needed | Best for | |
| 8 | +|---|---|---|---| |
| 9 | +| **A — ParcelPath** | Single API key → ParcelPath handles UPS + USPS | ParcelPath API key only | New operators, quick start | |
| 10 | +| **B — Direct** | TMS calls UPS/USPS APIs with the operator's own credentials | UPS OAuth + account number, USPS OAuth | Enterprise with negotiated rates | |
| 11 | +| **C — Hybrid** | Queries ParcelPath + direct accounts simultaneously | Both | Rate comparison, broker optimization | |
| 12 | + |
| 13 | +All three modes share the same ServiceQuote, Order, TrackingNumber, TrackingStatus, and File models. The difference is which bridge class processes the request. |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Setup |
| 18 | + |
| 19 | +### Mode A — ParcelPath (recommended default) |
| 20 | + |
| 21 | +1. Navigate to **Fleet-Ops → Management → Integrated Vendors → New**. |
| 22 | +2. Select **ParcelPath** in the provider picker. |
| 23 | +3. Enter your ParcelPath API key. Toggle **Sandbox** for testing. |
| 24 | +4. Configure options: |
| 25 | + - **Carrier filter**: UPS + USPS (default), UPS only, or USPS only |
| 26 | + - **Label format**: PDF or ZPL (thermal) |
| 27 | + - **Insurance default**: No insurance, Auto-insure all, or Ask per shipment |
| 28 | + - **Markup**: optional flat (cents) or percentage markup on top of ParcelPath rates |
| 29 | +5. Save. You're ready to rate and ship. |
| 30 | + |
| 31 | +### Mode B — Direct UPS |
| 32 | + |
| 33 | +1. Create a UPS developer account at [developer.ups.com](https://developer.ups.com). |
| 34 | +2. Create an app with access to Rating, Shipping, and Tracking APIs. |
| 35 | +3. In FleetOps: **Integrated Vendors → New → UPS**. |
| 36 | +4. Enter `client_id`, `client_secret`, and `account_number`. |
| 37 | +5. Toggle **Sandbox** for the UPS CIE test environment. |
| 38 | +6. Optional: configure markup, label format (PDF/ZPL), and Shipsurance insurance. |
| 39 | +7. Optional: set **Shipper Client** to scope this credential to a specific vendor (for brokers). |
| 40 | + |
| 41 | +### Mode B — Direct USPS |
| 42 | + |
| 43 | +1. Create a USPS developer account at [developer.usps.com](https://developer.usps.com). |
| 44 | +2. Enable the Prices, Labels, and Tracking products. |
| 45 | +3. In FleetOps: **Integrated Vendors → New → USPS**. |
| 46 | +4. Enter `client_id` and `client_secret`. No account number (USPS rates are zip-scoped). |
| 47 | +5. Toggle **Sandbox** for the USPS TEM test environment. |
| 48 | +6. Note: USPS labels are **PDF only** — no ZPL option. |
| 49 | + |
| 50 | +### Mode C — Hybrid |
| 51 | + |
| 52 | +1. Set up both a ParcelPath record and one or more direct carrier records. |
| 53 | +2. When creating an order, pass multiple facilitator IDs: |
| 54 | + ``` |
| 55 | + facilitator=integrated_vendor_pp,integrated_vendor_ups_direct |
| 56 | + ``` |
| 57 | +3. The controller queries all specified vendors and returns all rates in a single response. |
| 58 | +4. The UI renders each rate with a source badge: "via ParcelPath" or "your account". |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +## Broker / Multi-Tenant Configuration |
| 63 | + |
| 64 | +### The shipper_client_uuid workflow |
| 65 | + |
| 66 | +Brokers managing multiple shipper clients (each with their own UPS/USPS accounts) use the **Shipper Client** field on the IntegratedVendor form: |
| 67 | + |
| 68 | +1. Create one IntegratedVendor per carrier per shipper client, setting the **Shipper Client** dropdown to the corresponding Vendor record. |
| 69 | +2. Create one catch-all IntegratedVendor per carrier with **Shipper Client** left blank — this handles any order whose customer doesn't have a dedicated credential. |
| 70 | +3. When an order's customer is a Vendor, the `IntegratedVendorResolver` automatically resolves the correct credential: exact match first, catch-all fallback if no match, silent skip if neither exists. |
| 71 | + |
| 72 | +**Safety guarantee:** the resolver never routes through a mismatched credential. If an order's customer is Acme Corp and only TechCo has a dedicated UPS record (with no catch-all), UPS is silently dropped from the rate response rather than billing TechCo's account. |
| 73 | + |
| 74 | +### ParcelPath for brokers |
| 75 | + |
| 76 | +ParcelPath eliminates the multi-record problem: one ParcelPath IntegratedVendor covers all shipper clients. ParcelPath handles per-client billing internally. The broker's margin is managed via the markup option on the IntegratedVendor or within ParcelPath's own dashboard. |
| 77 | + |
| 78 | +--- |
| 79 | + |
| 80 | +## Rating |
| 81 | + |
| 82 | +| Endpoint | Mode A | Mode B | Mode C | |
| 83 | +|---|---|---|---| |
| 84 | +| `GET/POST /v1/service-quotes?facilitator=integrated_vendor_xxx&payload=payload_xxx` | ParcelPath API | UPS Rating API / USPS Prices API | All specified vendors queried | |
| 85 | + |
| 86 | +Rates are returned as `ServiceQuote` records with `ServiceQuoteItem` line items. The `meta` field carries carrier-specific data: |
| 87 | + |
| 88 | +**ParcelPath quotes:** |
| 89 | +```json |
| 90 | +{ |
| 91 | + "carrier": "UPS", |
| 92 | + "service_token": "ups_ground", |
| 93 | + "pp_rate_id": "rate_abc", |
| 94 | + "estimated_days": 5, |
| 95 | + "carrier_amount": 842, |
| 96 | + "insurance_available": true, |
| 97 | + "insurance_cost": 125 |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +**Direct UPS quotes:** |
| 102 | +```json |
| 103 | +{ |
| 104 | + "carrier": "UPS", |
| 105 | + "service_code": "03", |
| 106 | + "carrier_amount": 1000, |
| 107 | + "markup_amount": 50, |
| 108 | + "markup_type": "flat" |
| 109 | +} |
| 110 | +``` |
| 111 | + |
| 112 | +**Direct USPS quotes:** |
| 113 | +```json |
| 114 | +{ |
| 115 | + "carrier": "USPS", |
| 116 | + "mail_class": "PRIORITY_MAIL", |
| 117 | + "carrier_amount": 810, |
| 118 | + "markup_amount": 25, |
| 119 | + "markup_type": "flat" |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +--- |
| 124 | + |
| 125 | +## Label Purchase |
| 126 | + |
| 127 | +Selecting a ServiceQuote and confirming the order calls `createOrderFromServiceQuote` on the bridge, which: |
| 128 | + |
| 129 | +1. Calls the carrier's label API (ParcelPath `/v1/labels`, UPS Ship API, USPS Labels API). |
| 130 | +2. Decodes the base64 label binary (PDF or ZPL). |
| 131 | +3. Writes a `File` record under the `carrier-labels/` folder. |
| 132 | +4. Updates `Order.meta.integrated_vendor_order` with tracking number, carrier, and insurance details. |
| 133 | +5. The `LabelController::getLabel()` fallback serves the carrier label from Storage before falling back to the internal Blade label. |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## Tracking |
| 138 | + |
| 139 | +### Polling (primary) |
| 140 | + |
| 141 | +Three scheduled jobs run every 15 minutes: |
| 142 | + |
| 143 | +| Job | Scope | Code mapper | |
| 144 | +|---|---|---| |
| 145 | +| `PollParcelPathTrackingJob` | Orders with `parcelpath_shipment_id` | Pre-normalized by ParcelPath | |
| 146 | +| `PollUPSTrackingJob` | Orders with `shipmentIdentificationNumber` | I→IN_TRANSIT, D→DELIVERED, X→EXCEPTION, P→PICKED_UP, M→MANIFESTED, O→OUT_FOR_DELIVERY, RS→RETURN_TO_SENDER | |
| 147 | +| `PollUSPSTrackingJob` | Orders with carrier=USPS | ALERT→EXCEPTION; all others verbatim | |
| 148 | + |
| 149 | +Each job: `TrackingStatus::firstOrCreate` per event (idempotent), terminal transition via `ParcelPath::terminalOrderStatus()` (DELIVERED→completed, RETURN_TO_SENDER→returned). |
| 150 | + |
| 151 | +### Webhooks (supplementary) |
| 152 | + |
| 153 | +`POST /webhooks/parcel/{providerKey}` accepts push events from ParcelPath, UPS, or USPS. Same normalization and dedup key as the poll jobs. Shared-secret auth via `X-Webhook-Secret` header. The `TrackingStatusObserver` handles Order status transitions and SocketCluster broadcast for both poll and webhook paths. |
| 154 | + |
| 155 | +### Real-time updates |
| 156 | + |
| 157 | +When a `TrackingStatus` is created (by any path), the `TrackingStatusObserver` updates the parent Order's status and the existing `SendsWebhooks` trait + `ResourceLifecycleEvent` pipeline broadcasts the event to SocketCluster channels (`company.<uuid>`, `order.<publicId>`). The console and Navigator app receive updates without additional wiring. |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +## Batch Shipping |
| 162 | + |
| 163 | +`POST /v1/batch-shipments/rates` and `POST /v1/batch-shipments/purchase` enable bulk operations: |
| 164 | + |
| 165 | +- **Rates**: submit an array of shipments, each with pickup/dropoff/parcels/facilitator. Returns per-row rate results with `row_id`. |
| 166 | +- **Purchase**: submit an array of `service_quote_uuid` selections. Returns per-row tracking numbers + label file UUIDs. |
| 167 | +- **Idempotent**: checks PurchaseRate + Order.meta before buying, returns existing label if already purchased. |
| 168 | +- **Per-row isolation**: one row failing never aborts the batch. |
| 169 | + |
| 170 | +--- |
| 171 | + |
| 172 | +## Insurance (Shipsurance) |
| 173 | + |
| 174 | +**Mode A (ParcelPath):** insurance is managed by ParcelPath's API. The TMS stores the policy details from the label response. |
| 175 | + |
| 176 | +**Mode B (Direct):** operators can enable Shipsurance on the IntegratedVendor: |
| 177 | +1. Set `insurance_provider` to `Shipsurance`. |
| 178 | +2. Set `insurance_default` to `Auto-insure all` or `Ask per shipment`. |
| 179 | +3. Enter the `shipsurance_api_key`. |
| 180 | + |
| 181 | +Premium calculation: declared value rounded up to the next $100 × rate per $100 ($1.00 domestic, $1.50 international). |
| 182 | + |
| 183 | +--- |
| 184 | + |
| 185 | +## Service Types |
| 186 | + |
| 187 | +### ParcelPath (13 services) |
| 188 | +PP_UPS_GROUND, PP_UPS_GROUND_SAVER, PP_UPS_3DS, PP_UPS_2DA, PP_UPS_2DAM, PP_UPS_1DA, PP_UPS_1DAM, PP_UPS_1DASAVER, PP_USPS_PRIORITY, PP_USPS_EXPRESS, PP_USPS_GROUND_ADV, PP_USPS_FIRST, PP_USPS_MEDIA |
| 189 | + |
| 190 | +### UPS Direct (8 services) |
| 191 | +GROUND (03), GROUND_SAVER (93), 3DS (12), 2DA (02), 2DAM (59), 1DA (01), 1DAM (14), 1DASAVER (13) |
| 192 | + |
| 193 | +### USPS Direct (5 services) |
| 194 | +PRIORITY (PRIORITY_MAIL), PRIORITY_EXPRESS (PRIORITY_MAIL_EXPRESS), GROUND_ADVANTAGE (USPS_GROUND_ADVANTAGE), FIRST_CLASS (FIRST-CLASS_PACKAGE_SERVICE), MEDIA_MAIL (MEDIA_MAIL) |
| 195 | + |
| 196 | +--- |
| 197 | + |
| 198 | +## Dev Environment Notes |
| 199 | + |
| 200 | +### Docker bind mount |
| 201 | + |
| 202 | +Mount only `../fleetops/server` (not the full `../fleetops`) into `application`, `queue`, and `scheduler` services. The full mount drags `server_vendor/` (~58k files) and wedges Docker Desktop. |
| 203 | + |
| 204 | +### Running migrations |
| 205 | + |
| 206 | +```bash |
| 207 | +docker compose exec application php artisan migrate \ |
| 208 | + --path=/fleetbase/api/vendor/fleetbase/fleetops/server/migrations \ |
| 209 | + --realpath --force |
| 210 | +``` |
| 211 | + |
| 212 | +`--realpath` is required with absolute paths — without it Laravel silently finds nothing. |
| 213 | + |
| 214 | +### Running tests |
| 215 | + |
| 216 | +```bash |
| 217 | +docker run --rm -v ~/fleetbase-project/fleetops:/app -w /app \ |
| 218 | + fleetbase/fleetbase-api:latest \ |
| 219 | + sh -c "ln -sf server_vendor vendor && ./server_vendor/bin/pest" |
| 220 | +``` |
| 221 | + |
| 222 | +The `vendor → server_vendor` symlink is required because Pest hardcodes the `vendor/` path. |
0 commit comments