Skip to content

Fulfillment

Stores that hold their own stock, how an order is routed to shipping, pickup, carry-out or digital delivery, and what each path undoes when it fails.

An order’s physical lines can ship from the warehouse, ship from a store, wait for the customer at a store, or leave the till in a bag; its digital lines are delivered as codes. The commerce.route_fulfillment node decides which, and the built-in tpl-omnichannel-order and tpl-pos-sale flows are built around it (see Built-in flows).

Stock lives in one of two places:

  • Central stock is each product’s stock and reserved count: the warehouse.
  • Store stock is held per store and SKU, with its own stock and reserved counts. A store has pickup (customers can collect there) and ships (it can fulfil shipped orders) flags.

Every reservation and stock movement records whether it was central or at which store. Holds, releases and consumption move the stock they were made against, so a hold at a store never touches the warehouse.

A product has a kind, physical or digital. Digital products hold no stock: reserving skips them, and the back office shows them as “Digital, no stock”.

The workspace starts with two stores, both offering pickup and shipping, with coordinates: loc_soho (40.7233, -74.0030) and loc_mission (37.7599, -122.4148). TEE-01 has no central stock and is stocked at both stores; MON-27 has central stock and store stock; EBK-101 and GFT-25 are digital. The back office Stores table lists each store’s flags and available over on-hand stock per SKU.

An order names its store in fulfillment: { method, locationId? }, where method is one of ship, pickup, carry_out, digital. The New order form has Fulfillment and Store selects, and the order header shows the method and the store once the order is routed.

commerce.route_fulfillment has a method field: from_order (the default; uses order.fulfillment.method, or ship when the order has none), ship, pickup or carry_out. It only reads stock; reserving is a separate node. It takes these branches:

Branch Taken when
ship method is ship and some combination of the warehouse and shipping stores covers every physical line: central stock if it covers them all, else the nearest shipping store that does, else a split with each line at one place
pickup The order’s store offers pickup and has every physical line
carry_out The order’s store has every physical line
digital The order has a digital line. It is taken in addition to the physical branch, so a mixed cart takes two
unavailable Shipping: no warehouse or store holds the full quantity of some line (the reason is No warehouse or store has enough <SKUs>). For pickup and carry_out: the order has no store, the store lacks a line, or (pickup) it doesn’t offer pickup. The node’s summary says why

An order of only digital lines takes digital alone. On a routed branch the node writes the chosen method and locationId back onto order.fulfillment (for ship-from-store, the store it picked) and sets vars.fulfillmentLocation to the store’s name (the sources’ names joined with and on a split), or null for central stock. Later reserve, hand-over and notification steps read them.

On tpl-omnichannel-order, the pickup branch is:

  1. Hold at store: commerce.reserve_inventory holds the lines at order.fulfillment.locationId. A line the store can’t cover takes backorder, and the order fails with its payment voided.
  2. Ready for pickup: commerce.pickup_ready stages the hold as a shipment with carrier pickup, status ready_for_pickup, and the six-character pickup code as its tracking number. The code is vars.pickupCode.
  3. Pickup ready: a notification to the customer with the store (vars.fulfillmentLocation) and the code.
  4. Customer collects: waits for the pickup.collected event, with a 5-day timeout.
  5. Hand over: commerce.handover marks the staged pickup collected and consumes the held store stock.

Send pickup.collected when the customer has the goods, the way any wait event is sent: the Send pickup.collected button on the back office page, POST /api/v1/orders/:id/events with { "event": "pickup.collected" }, or a signed event from the store’s system. The payload is optional.

If nobody collects within the timeout, the wait takes timeout to the failed end Not collected. A failed end rolls back, so the store’s hold is released, the staged pickup is cancelled and the payment authorization is voided. The end is failed rather than cancelled for that reason: only a failure runs the rollbacks.

method: ship prefers the warehouse, then the shipping stores (ships set) nearest the customer. The sources are tried in that order, and the first one that holds every physical line ships the whole order. A store winner is written to order.fulfillment.locationId; the ship branch then reserves at that store and the carrier label is created as usual, and the store’s stock, not the warehouse’s, is consumed.

The ship-to comes from meta.shipTo when it is an address object (a plain string is ignored). Stores rank by:

  1. Distance, when the ship-to has latitude and longitude and the store has coordinates: great-circle distance. Stores without coordinates go after those with.
  2. Postal code, otherwise: the longest shared prefix with the ship-to’s postalCode, then the smallest numeric difference.

Ties, and orders with no ship-to, keep store id order. Send coordinates on the address with the order (see Inbound orders).

When no single source holds every line, each line goes to the first source (in the same order) that holds its full quantity. A line is never split across sources, and two lines of one SKU don’t count the same units twice. If a line fits nowhere the branch is unavailable, naming the short SKUs.

A split writes locationId on each line that ships from a store (order.lines[].locationId); a line shipping from the warehouse has none. The summary names every source (Ship from Warehouse and Mission store) and vars.fulfillmentLocation is the same names joined. Then:

  • commerce.reserve_inventory holds each line at its own location.
  • commerce.ship books one shipment per source location. The first has the usual shipment id, the others add _2, _3, and so on. The output has shipments ([{ shipmentId, tracking, locationId }]), vars.tracking is every tracking number joined with , , and the summary reads 2 UPS shipments: …. Its rollback cancels all of them.

In workspaces with the demo data loaded, the Ship from store, with an e-book scenario ships to New York with coordinates, so the tees, missing from the warehouse, ship from the nearest store, SoHo. Split shipment, nearest store sends eight monitors and three tees to San Francisco: the monitors ship from the warehouse and the tees from the Mission store.

carry_out is a sale where the customer leaves with the goods. The order’s store must have every physical line (pickup isn’t required). tpl-pos-sale is the flow for it: take payment with card_present, route carry_out at the till’s store, reserve from the store’s stock, hand over (which records a shipment with carrier carry_out, status collected, and consumes the stock), capture, and email a receipt only when order.contact.email is set.

Till sales arrive on the pos channel. The default order routes send b2c:pos and d2c:pos to tpl-pos-sale; see Inbound orders. The payment methods a flow can authorize with include card_present, wallet, gift_card and cash besides card, ach and terms.

tpl-omnichannel-order doesn’t sell at a till: its carry_out branch ends in Cannot fulfil.

commerce.deliver_digital issues one code per unit of each digital line (a licence key, download or gift card code), as <SKU>-XXXX-XXXX, status issued. The codes are vars.entitlements as [{ sku, code }], so a notification can include them. A retried step issues nothing twice.

On the omnichannel flow the digital branch runs in parallel with the physical one, and Capture payment joins every branch the order took: a mixed cart is captured once the shipment (or pickup hand-over) and the codes are all done. On the till flow, the digital branch joins capture directly.

Event Undone
A failed order rolls back commerce.reserve_inventory releases held stock (at the location it was held at); commerce.ship cancels every shipment it booked and, once none of the order’s shipments is live, restocks what they consumed (reservation restored); commerce.pickup_ready cancels the staged pickup; commerce.deliver_digital revokes the order’s issued codes (status revoked); commerce.authorize_payment voids the authorization; commerce.invoice takes an on-account invoice back off the customer’s balance
A cancel Does the same in one pass: releases holds, voids authorizations, cancels staged pickups along with open shipments, restocks shipped stock, reverses account charges, revokes codes, plus seller orders and payouts

Codes issued for a mixed cart are revoked when its physical part later fails, for example when the store runs out between routing and reserving. A completed hand-over has consumed its stock and has no rollback; returns go through the return steps.

order.ready_for_pickup is sent when commerce.pickup_ready finishes. order.delivered is sent when commerce.handover or commerce.deliver_digital finishes, so a mixed cart can send it twice. See Outbound webhooks.