Shipment
Source: src/data/shipment/
Overview
The Shipment module manages the lifecycle of physical shipments for orders. The shipping partner port (src/ports/shipping-partner.port.ts) is bound to the EasyPost adapter when EASYPOST_API_KEY is set; otherwise the port stays unbound and only the manual mark-shipped path is exercised. Both paths share the same service.
Status lifecycle
pending → label_created → in_transit → delivered
↘ exception
label_created is set by purchaseLabel after the partner returns a label. in_transit is set by markShipped (manual) or by the EasyPost webhook handler when the tracker first reports in_transit.
Service
ShipmentService
| Method | Description |
|---|---|
createForOrder(ctx, input) | Creates a shipment record linked to an order. |
markShipped(ctx, orderId, { carrier, trackingNumber }) | Manual path: transitions to in_transit, updates order, emits shipment.shipped + order.shipped. Idempotent. |
purchaseLabel(ctx, shipmentId, { serviceLevel?, carrier? }) | Buys a label via the bound ShippingPartnerPort. Transitions to label_created, persists carrier/tracking/labelUrl/cost, emits shipment.label-created. Idempotent (cached label re-returned). |
applyTrackingUpdate(ctx, trackingNumber, update, raw?) | Applies a partner tracking update (webhook or polling). Maps partner status → internal status, transitions, emits shipment.shipped/shipment.delivered/shipment.exception as appropriate. Idempotent. |
getById(ctx, id) | Returns shipment by id. |
getByOrderId(ctx, orderId) | Returns the shipment for an order. |
getByTrackingNumber(ctx, trackingNumber) | Returns the shipment for a tracking number (used by webhook handlers). |
listByTenant(ctx) | Lists all tenant shipments. |
Factory
createShipmentService(repo, orderService, eventBus?, {
shippingPartner?: ShippingPartnerPort,
labelDefaults?: ShipmentLabelDefaults,
})
eventBus is optional — omitting it disables event emission without errors. shippingPartner is optional — when omitted, purchaseLabel throws SHIPPING_PARTNER_NOT_CONFIGURED. labelDefaults resolves the from/to addresses + parcel for a given shipment; required when shippingPartner is provided.
Partner status mapping (used by applyTrackingUpdate)
| Partner status (EasyPost) | Internal status |
|---|---|
pre_transit | label_created |
in_transit | in_transit |
out_for_delivery | in_transit |
available_for_pickup | in_transit |
delivered | delivered |
return_to_sender | exception |
failure | exception |
cancelled | exception |
error | exception |
| anything else | no transition (no-op + logged) |
Repository
ShipmentRepository
| Method | Parameters | Returns |
|---|---|---|
getById | (ctx, id) | Promise<ShipmentRecord | null> |
getByOrderId | (ctx, orderId) | Promise<ShipmentRecord | null> |
getByTrackingNumber | (ctx, trackingNumber) | Promise<ShipmentRecord | null> |
listByTenant | (ctx) | Promise<ShipmentRecord[]> |
create | (ctx, input & { id }) | Promise<ShipmentRecord> |
update | (ctx, id, patch) | Promise<ShipmentRecord> |
delete | (ctx, id) | Promise<void> |
ShipmentRecord
| Field | Type | Required | Default |
|---|---|---|---|
| id | string | Yes | — |
| tenantId | string | Yes | — |
| orderId | string | Yes | — |
| status | ShipmentStatus | Yes | 'pending' |
| carrier | string | No | — |
| trackingNumber | string | No | — |
| labelUrl | string | No | — |
| shippingTypeCode | string | Yes | — |
| shippingCostCents | number | Yes | — |
| shippedAt | Date | No | — |
| deliveredAt | Date | No | — |
| metadata | Record<string, unknown> | Yes | {} |
| createdAt | Date | Yes | — |
| updatedAt | Date | Yes | — |
Validators
shipmentStatusSchema
Enum: pending, label_created, in_transit, delivered, exception
markShippedInputSchema
| Field | Type | Required |
|---|---|---|
| carrier | string | Yes |
| trackingNumber | string | Yes |
Events
See src/data/shipment/shipment.events.ts.
| Event | When |
|---|---|
shipment.label-created | Shipping partner returns a label and shipment transitions to label_created. |
shipment.shipped | Shipment transitions to in_transit (manual markShipped or partner tracking update). |
shipment.delivered | Shipment transitions to delivered via partner tracking update. |
shipment.exception | Shipment transitions to exception via partner tracking update. |
Consumers (customer DMs, dashboard, entitlements) subscribe in their own epic cards.
Indexes
(tenantId, orderId)— unique, covers per-order lookup(tenantId, trackingNumber)— sparse, covers tracking lookups(tenantId, status)— covers status-filtered admin queries
Seam points
Shipping partner adapter
src/ports/shipping-partner.port.ts defines ShippingPartnerPort with createLabel and getTracking. The EasyPost adapter (src/adapters/easypost-shipping.adapter.ts) is bound in src/init/api.init.ts when EASYPOST_API_KEY is set. Replace the binding to swap partners; the port + service contract stay unchanged.
Webhook entrypoint
POST /api/easypost/webhook consumes tracker.updated events. HMAC-SHA256 signature validation uses EASYPOST_WEBHOOK_SECRET. Missing secret in production is fail-closed (HTTP 500). Other event types are acked with 200 but not processed.
Ordering flow shipping-type selection
BotConfigService.listShippingTypes(ctx) returns the tenant's configured ShippingType[]. The ordering flow card calls this to present options.
Tenant origin/parcel defaults (TODO)
api.init.ts currently resolves fromAddress and parcel from environment variables (SHIPPING_ORIGIN_*, SHIPPING_PARCEL_*). When tenant config gains origin + default-parcel fields, replace labelDefaults.resolveCreateLabelOptions to read from BotConfig instead. Tracked as TODO(ledger-shipping-defaults) in the init module.