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).
Locations and stock
Section titled “Locations and stock”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) andships(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.
Routing
Section titled “Routing”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.
Buy online, pick up in store
Section titled “Buy online, pick up in store”On tpl-omnichannel-order, the pickup branch is:
- Hold at store:
commerce.reserve_inventoryholds the lines atorder.fulfillment.locationId. A line the store can’t cover takesbackorder, and the order fails with its payment voided. - Ready for pickup:
commerce.pickup_readystages the hold as a shipment with carrierpickup, statusready_for_pickup, and the six-character pickup code as its tracking number. The code isvars.pickupCode. - Pickup ready: a notification to the customer with the store (
vars.fulfillmentLocation) and the code. - Customer collects: waits for the
pickup.collectedevent, with a 5-day timeout. - Hand over:
commerce.handovermarks the staged pickupcollectedand 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.
Ship from store
Section titled “Ship from store”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.
Nearest store
Section titled “Nearest store”The ship-to comes from meta.shipTo when it is an address object (a plain string is ignored). Stores
rank by:
- Distance, when the ship-to has
latitudeandlongitudeand the store has coordinates: great-circle distance. Stores without coordinates go after those with. - 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).
Split shipments
Section titled “Split shipments”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_inventoryholds each line at its own location.commerce.shipbooks one shipment per source location. The first has the usual shipment id, the others add_2,_3, and so on. The output hasshipments([{ shipmentId, tracking, locationId }]),vars.trackingis every tracking number joined with,, and the summary reads2 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 and point of sale
Section titled “Carry-out and point of sale”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.
Digital delivery
Section titled “Digital delivery”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.
Undoing fulfillment
Section titled “Undoing fulfillment”| 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.
Events
Section titled “Events”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.