SOP: Carrier Label Creation
Document ID: WMS-SHIP-001 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Pack-station staff completing the outbound flow; managers troubleshooting label failures and voiding bad labels
1. Purpose
This procedure governs how a PACKED order becomes physically shipable — generating one or more carrier labels via ShipEngine, capturing tracking numbers, decrementing inventory, transitioning the order to SHIPPED (or PARTIALLY_SHIPPED), and triggering downstream Shopify fulfillment sync. This is the last step in the WMS outbound flow before the order leaves the warehouse with the carrier.
Label creation is the only ShipEngine integration in the WMS. It's also the most expensive step to get wrong — once a label prints, the cost of correction (void, reissue, customer notification, refund) is materially higher than catching the mistake before the call. The pre-flight checks in §4.2 exist for exactly this reason.
2. Scope
In scope:
- The pre-flight checks: order status, packing images, shipping address completeness
- Sync path:
POST /shipping/create-label— synchronous label creation, returns labels in the response - Async path:
POST /shipping/create-label-async+ SSE streamGET /shipping/labels/progress/:jobId— queued job with per-package progress events - USPS multi-package handling — special-case parallel-call behavior because USPS doesn't support multi-piece shipments at the ShipEngine API level
- UPS/FedEx multi-package — single ShipEngine call returning N labels
- Label voiding via
POST /shipping/void-label(ShipEnginePUT /labels/:id/void) - Tracking via
GET /shipping/track/:trackingNumber - Downstream Shopify fulfillment sync via the queued
SYNC_SHOPIFY_FULFILLMENTjob - W-Pulls tracking callback for orders sourced from W-Pulls
Out of scope:
- Picking, packing, weighing, dimensioning — see WMS-PICK-001 through WMS-PACK-001
- Carrier setup / API key management / preset configuration — out of routine ops; environment-level concern
- Address validation as a standalone tool —
/shipping/validate-addressexists but is consumed by the order detail / shipping form, not a routine procedure - Rate shopping for customer-facing checkout — this SOP covers the warehouse side; customer-facing rate quotes are an integration concern with the storefront
- Customer-return labels — see WMS-RET-002 (different code path: ShipEngine return-label endpoint)
3. Roles & permissions
API enforcement:
GET /shipping/carriers— auth not enforced (designed as a public read for the order form)POST /shipping/validate-address,POST /shipping/rates/:orderId,POST /shipping/create-label,POST /shipping/create-label-async,POST /shipping/void-label,GET /shipping/track/:trackingNumber,GET /shipping/order/:orderId/packages,GET /shipping/labels/job/:jobId, SSEGET /shipping/labels/progress/:jobId— all checkrequest.user?.suband return HTTP 401Unauthorizedif absent. No role gate.
| Role | View carriers / track | Validate address | Get rates | Create label | Void label |
|---|---|---|---|---|---|
| READONLY | ✓ | — | — | — | — |
| STAFF | ✓ | ✓ | ✓ | ✓ | ✓ |
| MANAGER | ✓ | ✓ | ✓ | ✓ | ✓ |
| ADMIN | ✓ | ✓ | ✓ | ✓ | ✓ |
| SUPER_ADMIN | ✓ | ✓ | ✓ | ✓ | ✓ |
Operational expectations:
- Voiding a label is a real money decision (carrier-specific void windows: USPS 30 days, UPS 90 days, FedEx 14 days, but the WMS doesn't enforce these — the ShipEngine API does). Manager review for non-trivial voids is the norm.
- The async path is strongly preferred for orders with 3+ packages or any USPS multi-package shipment. The sync path can hang the page for 10+ seconds and timeouts on slow ShipEngine responses are unrecoverable.
- Pickers / packers don't typically create labels — that's a pack-station role. The label creation usually fires from the fulfillment detail page after
Order.status: PACKED.
4. Procedures
4.1 Pre-flight: prerequisites for label creation
Per ShippingService.createLabels (lines 528–600), the following checks fire before any ShipEngine call:
| Check | Failure | Resolution |
|---|---|---|
| Order found | HTTP 404 Order {id} not found | Verify order ID. Has it been deleted? |
Order status in ['PACKED', 'SHIPPED', 'PARTIALLY_SHIPPED'] | HTTP 400 Order must be packed before shipping | Pack the order first per WMS-PACK-001. The check accepts SHIPPED/PARTIALLY_SHIPPED so additional packages can be added to a partially-shipped order. |
At least one packing image attached (order.packingImages.length > 0) | HTTP 400 Order {orderNumber} cannot be shipped without packing images | Capture a packing photo per WMS-PACK-001 §4.7. This is a hard gate — no images, no label. |
Carrier code + service code valid (validateCarrierService) | HTTP 400 with carrier-specific message | Pick a valid carrier+service combo. The /shipping/carriers endpoint returns the valid combinations. |
| Shipping address present and complete (line1, city, state, zip, country) | HTTP 400 Shipping address is required or address-field error | Provide a complete address on the order or via the shippingAddress body field. |
| Each package has positive weight | HTTP 400 Package {n} must have a valid weight | Weight comes from the pack flow (per WMS-PACK-001). If missing, re-pack with weight captured. |
⚠ Packing-image hard gate is a real ergonomic constraint. Per WMS-PACK-001 §4.7, photos are optional and unenforced during packing — but required at shipping. This means an order can pack successfully and then fail at label creation with a confusing error. The fix is twofold: (a) train pack-station staff to always capture at least one photo (it's the easiest insurance against chargebacks); (b) consider making the photo requirement enforced at pack time per WMS-PACK-001 §8 so the failure happens earlier.
4.2 The two creation paths — sync vs. async
The WMS exposes two endpoints for label creation. Choose based on order size and reliability needs.
Sync (POST /shipping/create-label) | Async (POST /shipping/create-label-async) | |
|---|---|---|
| Response | The labels themselves, in the response body | A jobId to poll/stream for progress |
| Latency | Blocks until ShipEngine finishes (10s+ for multi-package USPS) | Returns immediately |
| Retry behavior | Caller retries the whole request on failure | No automatic retry (attempts: 1 is set explicitly per the route comment at lines 235–238 — ShipEngine label calls are not idempotent, so a retry could create a duplicate label and bill for it) |
| Progress events | None | Per-package SSE stream via GET /shipping/labels/progress/:jobId |
| Best for | Single-package orders (UPS, FedEx, single-package USPS) | Multi-package orders, USPS multi-package, any order where the user shouldn't wait |
| Idempotency | None — caller must avoid double-submit | Job ID is stable per order (create-label-${orderId} per line 231–234) — BullMQ deduplicates waiting/active jobs by ID, but allows new jobs after failure |
The async path's attempts: 1 is deliberate. ShipEngine charges per label creation; a retry on a partial failure (label 1 created, label 2 failed) would create a duplicate label 1 and bill for it. The async path fails the entire job on any package-level failure, surfaces it to the user, and lets the user manually void any leaked labels and retry. See §7 troubleshooting for the recovery procedure.
4.3 Sync path — single-package label
Use when: Single-package order with UPS, FedEx, or a single-package USPS shipment. You're confident the call will complete in <10 seconds.
Steps:
- From the fulfillment page (per WMS-PACK-001), the
ShippingLabelFormUI block callsPOST /shipping/create-labelwith{ orderId, carrierCode, serviceCode, packages: [...], shippingAddress?, items?, notes? }. - The route validates pre-flight (per §4.1).
- The service runs
createLabelswhich:- Normalizes the carrier code (frontend uses
usps, ShipEngine expectsstamps_comperresolveShipEngineCarrierat line 545). - Builds the warehouse and customer addresses.
- Determines the branch (single vs. multi vs. USPS multi).
- For single-package: builds one ShipEngine
shipmentwith one package, POSTs to${baseUrl}/labels, gets back a label. - Commits the labels via
commitLabels— one big transaction per §4.5.
- Normalizes the carrier code (frontend uses
- Response contains:
success: true,label,labels[0],totalCost,orderId,orderNumber,isTestLabel(true ifSHIPENGINE_SANDBOX === "true"). - The UI shows the label, prints the PDF on the thermal label printer, and the order shows
SHIPPED(orPARTIALLY_SHIPPED).
4.4 Async path — multi-package or USPS multi-package
Use when: Multi-package orders, any USPS shipment with >1 package, or any case where the user shouldn't wait on a sync response.
Steps:
- The UI calls
POST /shipping/create-label-asyncwith the same body shape as sync. - The route enqueues a BullMQ job with stable ID
create-label-{orderId}andattempts: 1. Returns{ jobId }immediately. - The UI opens an SSE connection to
GET /shipping/labels/progress/:jobId?token={accessToken}(the SSE route requirestokenas a query parameter — line 447–448). - As the worker (
shipping.processor.ts:46-302) progresses, it emitsLabelProgressEventevents:- For UPS/FedEx multi-package: one ShipEngine call returns N labels; the worker emits progress events as it processes each.
- For USPS multi-package: each package gets its own ShipEngine
POST /labelscall; emits progress per package.
- On completion, the worker commits via
commitLabels(same transaction as sync — see §4.5) and emits a final completion event with the labels and total cost. - The UI shows progress, then the labels.
- Fallback: if the SSE connection drops, the UI can poll
GET /shipping/labels/job/:jobIdto read the current state of the job.
⚠ The SSE auth pattern is unusual. The SSE endpoint takes the access token as a query parameter (
?token=...) rather than the standardAuthorizationheader — because EventSource doesn't support custom headers. Per the route comment,verifyAccessToken(queryToken)is called explicitly. If you build a new SSE consumer, follow the same pattern.
4.5 What commitLabels writes
Both sync and async paths call commitLabels (per shipping.service.ts:1701–1924) at the end. This is one of the largest transactions in the WMS.
Per package label received from ShipEngine:
- One
ShippingPackagerow withorderId,carrierCode,serviceCode,packageCode,trackingNumber,labelUrl,cost,currency,weight,dimensions(JSON), and oneShippingPackageItemper shipped SKU (sku, productName, quantity, unitPrice).
Per order, atomically:
- All
Allocationrows for the order withstatus: ALLOCATEDare flipped toPICKED, pickedAt: now(catching any unbumped allocations). - For each shipped SKU: walks
Allocationrows inallocatedAt ASCorder (FIFO) and decrements the linkedInventoryUnit.quantityby the shipped quantity. ThrowsCannot ship {n} of {sku}: insufficient PICKED inventoryif running short — this is the inventory-truth check. - Each
OrderItem.quantityShippedincrements by the shipped quantity (atomic increment, not overwrite). - All
Allocationrows for the order withstatus: PICKEDare flipped toRELEASED, releasedAt: now. - All
OrderItemrows are re-read; if every item hasquantityShipped >= quantity,Order.status: SHIPPEDandshippedAt: now. OtherwiseOrder.status: PARTIALLY_SHIPPED. - The order's
trackingNumberfield gets all new tracking numbers joined with comma-separation (deduped against existing). - Best-effort: writes to
fulfillment_eventsfor downstream consumers.
Outside the transaction (after commit):
- Shopify fulfillment sync is enqueued as a separate
SYNC_SHOPIFY_FULFILLMENTjob (pershipping.processor.ts:264–272). The job:- Skips orders without a
shopifyOrderId. - Skips orders with
sourceStore IN ('MANUAL', 'WPULLS', 'STOREFRONT')(these aren't Shopify orders). - Otherwise calls
enqueueShopifyFulfillmentCreatewithstoreKey,trackingNumbers,trackingUrls,carrier,shippedItems, andnotifyCustomer: true(default).
- Skips orders without a
- W-Pulls tracking callback runs if
order.source === 'WPULLS'andwpullsShipmentRequestIdis set. CallsnotifyWPullsTracking(...)with the first tracking number.
Result:
- The order is officially
SHIPPED(orPARTIALLY_SHIPPED). - Inventory has been decremented from the source
InventoryUnitrows. - One
ShippingPackageper physical box exists with its label URL and tracking number. - Shopify (if applicable) gets the fulfillment marked as shipped within seconds.
- The customer receives a Shopify-driven shipment notification email (default — controlled by
notifyCustomer: true).
4.6 USPS multi-package — the special case
USPS does not support multi-piece shipments at the ShipEngine API level. If you need to ship 3 packages via USPS, ShipEngine requires 3 separate label calls — they cannot be combined into one shipment.
The service handles this transparently per shipping.service.ts:626–716:
needsSeparateLabels = isUsps(carrierCode) && packages.length > 1
When needsSeparateLabels is true:
- The service builds
packages.lengthseparate ShipEngine shipments, each with one package. - All shipments are POSTed in parallel via
Promise.allSettled. - Successes are collected; failures are logged and excluded.
- If all fail, throws
All label creations failed: {firstReason}. - If any succeeded, those become the labels — partial success is allowed.
The packer sees this as N tracking numbers per order, even though "the customer received one shipment" — Shopify's partial-fulfillment quantity sync (per §4.5 callout) handles this correctly.
⚠ Partial USPS multi-package failure is the riskiest mode in this SOP. If 2 of 3 USPS labels succeed and 1 fails, you've got 2 paid-for labels with no recovery built in. The job won't auto-retry (per the
attempts: 1decision). Manual recovery: void the 2 successful labels per §4.7, fix whatever caused the failure (usually weight or address validation), retry. See §7 troubleshooting.
4.7 Voiding a label
Use when: Wrong carrier, wrong address, voided due to weight discrepancy, customer cancelled order before pickup, label was misprinted (rare — Zebra catches most), or recovery from a partial failure per §4.6.
Prerequisites:
- The carrier-specific void window hasn't expired. WMS doesn't enforce this; ShipEngine returns an error if too late.
- The label was created via WMS (you have
labelIdfrom theShippingPackage.labelIdor the original create response).
Steps:
- From the order detail page (or the shipping packages list), tap Void Label on the package.
- The UI calls
POST /shipping/void-labelwith{ labelId, packageId }. - The service calls ShipEngine
PUT /labels/{labelId}/void. - On success, ShipEngine returns
{ approved: true, message }. - The service updates
ShippingPackage.voidedAt: now(pershipping.service.ts:1070–1075). - Returns the void result to the UI.
What void does NOT do:
- Does not reverse the inventory decrement. The
commitLabelstransaction already moved units fromPICKEDto consumed (decremented fromInventoryUnit.quantity). Voiding the label does not restore the inventory. If you need to put the units back into inventory, that's a separateWMS-INV-001 §4.xaction. - Does not change
Order.status. ASHIPPEDorder with a voided label staysSHIPPED. If the order is being cancelled, that's a separate cancel-order workflow. - Does not refund the carrier charge automatically. ShipEngine processes the void on the carrier side; refund timing varies by carrier.
⚠ Voiding a label and reversing inventory are two separate operations. If the customer cancelled, you usually want both: void the label, then move the picked-but-not-yet-consumed-from-source units back to inventory via WMS-INV-001 §4.1. Don't assume void includes inventory rollback.
5. Reference
5.1 Sync vs. async — which to use
| Order type | Path |
|---|---|
| Single-package UPS/FedEx | Sync — fast, simple |
| Single-package USPS | Sync — fast, simple |
| Multi-package UPS/FedEx | Async preferred — UI shows progress |
| Multi-package USPS (any count) | Async required in practice — multiple ShipEngine calls in parallel, sync would be 10s+ |
| Any order from a slow connection | Async — won't time out the request |
5.2 ShipEngine carrier code mapping
The frontend uses friendly carrier codes that don't always match ShipEngine's expected codes. The service normalizes via resolveShipEngineCarrier at line 545:
| Frontend code | ShipEngine code |
|---|---|
usps | stamps_com |
ups | ups (no change) |
fedex | fedex (no change) |
| Other | Pass-through (validate via validateCarrierService) |
If you add a new carrier, both resolveShipEngineCarrier and validateCarrierService need updates plus the carrier presets in getCarriersAndPresets.
5.3 What the warehouse address looks like
Per shipping.service.ts:586–595, the warehouse address is built from environment variables:
| Field | Env var | Default |
|---|---|---|
| name | WAREHOUSE_NAME | WMS Warehouse |
| company_name | WAREHOUSE_COMPANY | Your Company |
| address_line1 | WAREHOUSE_ADDRESS1 | 123 Warehouse St |
| city_locality | WAREHOUSE_CITY | Los Angeles |
| state_province | WAREHOUSE_STATE | CA |
| postal_code | WAREHOUSE_ZIP | 90210 |
| country_code | hardcoded | US |
| phone | WAREHOUSE_PHONE | 555-123-4567 |
If your environment variables are unset, labels print with placeholder addresses. Verify these are set in production. The defaults are dev-friendly but will create real labels with wrong shipper info.
5.4 Shipping events
| Event type | When |
|---|---|
order:shipped | (via commitLabels events emit, captured in fulfillment_events) |
order:partially_shipped | When some items remain unfulfilled after commitLabels |
| Shopify fulfillment status update | Via the queued SYNC_SHOPIFY_FULFILLMENT job |
5.5 Related SOPs
- WMS-PACK-001 §4.7 — Packing photos (the hard gate for shipping)
- WMS-PICK-003 — Short-pick recovery (where
PARTIALLY_SHIPPEDorders originate) - WMS-INV-006 — Backorder resolution (for the unshipped portion of partial orders)
- WMS-RET-002 — Customer return labels (different code path, similar ShipEngine integration)
- WMS-AUD-002 — Shipping variance and chargeback investigation (when written)
6. Audit & compliance
The shipping flow has strong audit data — better than picking or packing:
ShippingPackagerows — one per physical box per order. Includes carrier, service, tracking number, label URL, cost, weight, dimensions, items list. Insert-only (no update exceptvoidedAt).ShippingPackageItemrows — one per shipped SKU per package. Sku, quantity, productName, unitPrice. Insert-only.fulfillment_eventsrows —order:shipped/order:partially_shippedper state transition.Order.trackingNumber— comma-separated list of all tracking numbers ever issued for the order.- ShipEngine audit — ShipEngine itself is the carrier-facing audit log. You can correlate by
labelId,trackingNumber, orshipment_costagainst ShipEngine's dashboard. - Shopify fulfillment — the
enqueueShopifyFulfillmentCreatecall writes to Shopify's order fulfillment record, which is its own audit. - Inventory consumption — the
Allocation.RELEASEDstatus +InventoryUnit.quantitydecrement happens in the same transaction;inventory_eventsmay capture this depending on the inventory service's behavior.
Manager weekly review:
- Pull
ShippingPackage WHERE createdAt > now() - 7 days AND voidedAt IS NULL— all valid labels created this week. Sumcostfor the carrier-cost report. - Pull
ShippingPackage WHERE voidedAt IS NOT NULL AND createdAt > now() - 7 days— voids this week. High count = problem (wrong addresses, weight mismatches, picking errors). - Pull
Order WHERE status = 'PARTIALLY_SHIPPED' AND updatedAt < now() - 24 hours— orders sitting partially shipped without follow-up. May need WMS-PICK-003 §4.4 (split) to track the unshipped portion as a child order.
Quarterly governance:
- Carrier cost trend (sum of
ShippingPackage.costper carrier per month). Negotiate rates if trending high. - Void rate (voids / total labels). >2% indicates an upstream pack/address/weight problem.
- USPS multi-package partial-failure rate. If non-zero, see §4.6 callout — investigate and fix.
- Shopify fulfillment sync failure rate (queued vs. landed in Shopify). Check
#wms-supportqueue health if drifting.
7. Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
Order must be packed before shipping (HTTP 400) | Order is ALLOCATED, PICKED, or other non-pack status | Pack the order per WMS-PACK-001. |
Order {orderNumber} cannot be shipped without packing images (HTTP 400) | Hard gate per §4.1 | Capture a packing photo per WMS-PACK-001 §4.7. Then retry. |
Package {n} must have a valid weight (HTTP 400) | Packing didn't capture weight | Re-pack with weight. The pack-time form should require it; if the pack completed with weight 0, that's a UI bug. |
Shipping address is required (HTTP 400) | No address on the order or in the request body | Provide via the order detail page or pass shippingAddress in the body. |
Sync create-label hangs >30s | ShipEngine API slow, timeout imminent | Use the async path. Sync should not be used for any order where 30s would matter. |
Async job stuck in waiting for >5 min | Worker queue is unhealthy or dead-letter | Check #wms-support queue health. If stuck, void any leaked labels and retry via async (the stable jobId will replace, not duplicate). |
Async job failed with attempts: 1 and surfaced an error | Per §4.2, attempts: 1 is deliberate to prevent duplicate label charges | Read the error. Fix the underlying issue (weight, address, carrier service combo). Void any leaked labels per §4.7. Retry. |
| USPS 3-of-3 multi-package: 2 labels created, 1 failed | The Promise.allSettled succeeded the 2, surfaced the 1's error | Don't retry the whole call — that would create 2 more duplicate labels. Void the 2 successful labels per §4.7, fix the underlying issue, retry the whole order. The 2 voided labels will refund (carrier-dependent timeline). |
| Label voided but inventory wasn't returned | Per §4.7 callout, void doesn't reverse inventory | Move the appropriate units back to inventory via WMS-INV-001 §4.1. |
Cannot ship {n} of {sku}: insufficient PICKED inventory (HTTP 500-ish) | The commitLabels transaction calculated more shipped quantity than was actually allocated/picked | Check Allocation rows for the order — there may be a mismatch between what's in the package items list and what's actually allocated. Cross-reference the pack flow per WMS-PACK-001 §4.6 callout (multi-bin overwrite bug). |
| Shopify shows the order as fulfilled even though I voided the label | Shopify fulfillment was synced before void; voiding doesn't auto-roll back Shopify | Manually update Shopify fulfillment status, or cancel-and-recreate via the Shopify admin. Fragile — flagged in §8. |
isTestLabel: true in the response | SHIPENGINE_SANDBOX === "true" env var | This is dev/staging behavior. Verify production env doesn't have the sandbox flag set. Test labels are not real and won't ship. |
Carrier code error unknown carrier | Frontend sent an unmapped carrier code | Add the mapping to resolveShipEngineCarrier per §5.2 or use one of the supported codes. |
| Tracking number missing from response despite success | ShipEngine returned a label but no tracking_number | Rare — usually a carrier-side delay. Wait a few minutes and re-fetch via GET /shipping/order/:orderId/packages. |
| W-Pulls notification didn't fire after ship | wpullsShipmentRequestId was null on the order | Order wasn't from W-Pulls. Skip — this is correct behavior. |
8. Escalation
- Make the packing-image gate enforce at pack time. Per §4.1 callout, the photo requirement is enforced at shipping but optional at packing. This creates a confusing failure mode (pack succeeds, label fails). Move the gate up. Cross-reference WMS-PACK-001 §8.
- USPS multi-package partial-failure recovery UI. Today the recovery from a 2-of-3 partial failure (per §4.6) is a manual void-then-retry dance. A UI flow that detects partial success, shows the user "2 created, 1 failed — void successes and retry, or accept partial?" would close the highest-friction shipping scenario.
- Inventory rollback on void. Voiding a label doesn't reverse the inventory consumption. A
voidAndRollbackflow that voids the label and moves the SKU'squantityShippeddecrement back into inventory atomically would close the §4.7 footgun. - Shopify fulfillment auto-rollback on void. Same problem at the Shopify integration layer. Today a voided label leaves Shopify thinking the order shipped. Add a
fulfillmentVoidenqueue alongsideenqueueShopifyFulfillmentCreateto keep both sides consistent. - Carrier rate caching. Live
getRatescalls for every order detail page view are slow and expensive. Cache rates per (orderId, address-hash) for 5–10 minutes. Engineering ticket. - Address validation as a hard gate. Today
/shipping/validate-addressexists as a separate call but isn't enforced. A bad address gets caught only at ShipEngine call time. Wire validation into the order form so bad addresses surface at order creation, not at label generation. - Suspected ShipEngine charges for voids that didn't refund. ShipEngine's accounting — outside the WMS, but worth correlating monthly. Pull
ShippingPackage WHERE voidedAt IS NOT NULLfrom the past 60 days, cross-reference with ShipEngine's invoice for void credits. Discrepancies escalate to ShipEngine support.
9. Revision history
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | [DATE] | [NAME] | Initial release. Documents both label creation paths (sync POST /shipping/create-label and async POST /shipping/create-label-async + SSE /shipping/labels/progress/:jobId) grounded in apps/api/src/routes/shipping.routes.ts:33-470, packages/domain/src/services/shipping.service.ts:528-1924, apps/worker/src/processors/shipping.processor.ts:46-449. Documents the packing-image hard gate (per shipping.service.ts:578-583) — the ergonomic disconnect with WMS-PACK-001 §4.7's optional photo. Documents the attempts: 1 design decision for async (per route comment lines 235-238) — deliberate non-retry to prevent ShipEngine duplicate-label charges. Documents the USPS multi-package special case (needsSeparateLabels = isUsps(carrierCode) && packages.length > 1 at line 628) and its Promise.allSettled parallel-call behavior with partial-success handling (lines 633-716). Documents the full commitLabels transaction (lines 1701-1924) — ShippingPackage creation, FIFO inventory decrement walk via Allocation rows, Order.status SHIPPED vs. PARTIALLY_SHIPPED determination based on per-item quantityShipped >= quantity check. Documents the post-transaction Shopify fulfillment enqueue (per shipping.processor.ts:264-273) and W-Pulls tracking callback (lines 276-294). Documents the void label flow including the explicit gap that void does NOT reverse inventory or roll back Shopify fulfillment. Documents the carrier code normalization (usps → stamps_com per resolveShipEngineCarrier). Cross-references WMS-PACK-001 (upstream gate), WMS-PICK-003 (PARTIALLY_SHIPPED origins), WMS-INV-006 (backorder for unshipped portion), WMS-RET-002 (return labels). |