Skip to main content

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

MethodDescription
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_transitlabel_created
in_transitin_transit
out_for_deliveryin_transit
available_for_pickupin_transit
delivereddelivered
return_to_senderexception
failureexception
cancelledexception
errorexception
anything elseno transition (no-op + logged)

Repository

ShipmentRepository

MethodParametersReturns
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

FieldTypeRequiredDefault
idstringYes
tenantIdstringYes
orderIdstringYes
statusShipmentStatusYes'pending'
carrierstringNo
trackingNumberstringNo
labelUrlstringNo
shippingTypeCodestringYes
shippingCostCentsnumberYes
shippedAtDateNo
deliveredAtDateNo
metadataRecord<string, unknown>Yes{}
createdAtDateYes
updatedAtDateYes

Validators

shipmentStatusSchema

Enum: pending, label_created, in_transit, delivered, exception

markShippedInputSchema

FieldTypeRequired
carrierstringYes
trackingNumberstringYes

Events

See src/data/shipment/shipment.events.ts.

EventWhen
shipment.label-createdShipping partner returns a label and shipment transitions to label_created.
shipment.shippedShipment transitions to in_transit (manual markShipped or partner tracking update).
shipment.deliveredShipment transitions to delivered via partner tracking update.
shipment.exceptionShipment 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.