Dispatch Tracking, Cancellations & Returns¶
What happens to an order after it's placed: dispatching it with a tracking number, cancelling it before it ships, and taking items back after it has shipped. Each is available in the admin, the REST admin API and over MCP, and runs through the same service code in all three.
Dispatch tracking¶
When an order is dispatched you can record how it's being delivered:
| Field | Limit | Notes |
|---|---|---|
carrier |
100 characters | e.g. Royal Mail |
trackingNumber |
100 characters | |
trackingUrl |
500 characters | Must be a full http:// or https:// link, because it's rendered as a link in the email |
All three are optional, so an order can still be dispatched without tracking. Values are trimmed, and an invalid value stops the dispatch with a message naming the field.
Where it's stored. Order.dispatchTracking (DispatchTracking, with a recordedAt date).
The admin order view, orders get over MCP and the REST Order model all show it.
How it's recorded. OrderService.dispatchOrder(order, user, siteContext, tracking) passes the
values to the site's dispatchOrder pipeline as additionalParameters (carrier,
trackingNumber, trackingUrl). The recordDispatchTrackingProcessor step (priority
CORE + 200, after setOrderToDispatchedProcessor) saves them on the order and writes
DISPATCH_TRACKING_RECORDED <carrier> <number> to the activity log. The dispatch email step runs
after it, so the email includes the tracking. The effective pipeline is:
orderStateValidationProcessor(the order must be PACKING)conditionalCaptureProcessor(captures if the site captures at dispatch)setOrderToDispatchedProcessorrecordDispatchTrackingProcessorsendOrderDispatchEmailProcessorredirectToOrderPageProcessor
The email. email/orderDispatched (and its -text variant) shows a "Track your parcel"
block with the carrier, the tracking number and a link, when the order has tracking. The
wording comes from the orderNotificationEmail.tracking* message keys (English and Dutch).
Resending the dispatch email¶
OrderEmailResendService.resendDispatchEmail queues the customer's dispatch email again, with the
order's current tracking, and logs DISPATCH_EMAIL_RESEND_QUEUED. The dispatch pipeline and the
resend share OrderDispatchEmailSender, so both use the same site settings
(orderDispatchTemplate, orderDispatchSubjectPattern, orderNotificationFrom*). A resend is
refused if the order isn't DISPATCHED or DELIVERED, and nothing is sent if the order has no
customer email address.
Where to find it¶
| Dispatch with tracking | Resend dispatch email | |
|---|---|---|
| Admin | Order view → Dispatch Order opens a form for the tracking | Order view → Resend Dispatch Email (admins only) |
| REST | POST /v1/admin/orders/{orderId}/dispatch with an optional DispatchOrderRequest body |
POST /v1/admin/orders/{orderId}/dispatch/resend-email (409 if not dispatched, 422 if no customer email) |
| MCP | manageFulfilment dispatch with carrier, trackingNumber, trackingUrl |
manageOrderEmails resendDispatch |
Over MCP the resend is refused if the dispatch email was sent for the order in the last 15 minutes, whether by the dispatch itself or by an earlier resend.
Order cancellation¶
A SUBMITTED or PACKING order can be cancelled. Cancelling gives the customer their money back, marks the order CANCELLED, puts its stock back and emails the customer. A DISPATCHED or DELIVERED order can't be cancelled; it needs a return.
What happens to the payment¶
OrderCancellationService.plan(order, siteContext) works this out before anything changes. The
admin modal, the REST dry run and the MCP dry run all show the plan.
| Payment state | What cancelling does |
|---|---|
CAPTURED, PARTIAL_REFUND (or legacy PAID) |
REFUND: what's left (captured amount minus refunds so far) is refunded by running the site's refundOrder pipeline |
AUTHORISED (not captured yet, e.g. a site that captures at pack or dispatch) |
VOID: the Stripe PaymentIntent is cancelled, releasing the hold on the card; the payment becomes VOIDED |
No payment, INITIAL, REFUNDED, VOIDED |
NONE |
Anything else (PENDING_REFUND, DISPUTED, FAILED...) |
Refused: sort the payment out with the provider first |
Cancelling is also refused when:
- the order has a line that cancelling can't undo: a subscription, a virtual gift card, a gift card top-up or a donation (their side effects happen at checkout);
- the order was paid partly by another payment intent (a gift card or split tender), because
the
refundOrderpipeline only refunds the main card payment.
The cancelOrder pipeline¶
OrderCancellationService.cancel(order, user, siteContext, reason, restock) checks the plan, then
runs the site's cancelOrder pipeline through OrderService.cancelOrder. The pipeline gets the
reason and restock values as additionalParameters:
orderStateValidationProcessor: the order must be SUBMITTED or PACKING (validStatesconfig, defaulting to those two).releaseOrderPaymentProcessor: refunds or releases the payment as above, and recordscancellationPaymentAction/cancellationPaymentAmounton the order. If the provider refuses, it stops the pipeline witherror, so the order is left as it was.setOrderToCancelledProcessor: sets the state to CANCELLED and stores the reason in thecancellationReasonorder property and the activity log.restockCancelledOrderProcessor: puts the stock back (see below), unlessrestockisfalse.sendOrderCancellationEmailProcessor: emails the customer.
The pipeline is annotation-driven (@PipelineStep), so it reaches every site on deploy.
defaultProcessors.json has rows for it, and system update 84.json adds the
orderStateValidationProcessor / cancelOrder config row to existing installs, so the valid
states can be changed per site in the pipeline admin.
Restocking¶
At checkout, updateInventory now records what it actually took in the order's stockTaken
property ([{skuId, quantity}]). A SKU that was already out of stock isn't decremented, so it
isn't recorded. Restocking puts back exactly that list with InventoryService.increaseStock on the
default warehouse. Orders placed before this change have no record, so their line quantities are
used instead. Untracked SKUs (unlimited stock) are left alone, and quantitySold isn't reduced.
The customer email¶
email/orderCancelled (HTML and text) tells the customer the order was cancelled and whether they
were refunded or had the hold on their card released, with the amount. The cancellation reason is
internal and isn't included.
Config key (order-cancellation-emails) |
Default | |
|---|---|---|
order-cancellation-email-enabled |
true |
Send the email |
order-cancellation-email-subject |
Order #{0} has been cancelled |
{0} is the order number |
order-cancellation-email-template |
orderCancelled |
Template under email/ |
The From and BCC addresses are the same orderNotificationFrom* settings the dispatch email uses.
Where to find it¶
| Admin | Order view → Cancel Order (admins only) opens a confirmation showing what will be refunded or released and how much stock goes back, with a required reason and a restock checkbox. Manage Orders → Cancelled Orders lists them. |
| REST | POST /v1/admin/orders/{orderId}/cancel with {reason, restock, dryRun}. 409 if the order can't be cancelled or the provider refused (the order is unchanged). |
| MCP | manageCancellations (mcp:admin): reason and idempotencyKey are required, dryRun previews, and a refund is capped by mcp-refund-limit. Releasing an authorisation moves no money, so the limit doesn't apply to it. |
Effect on the rest of the platform¶
OrderStates.CANCELLEDcounts as placed (isPlaced()), so a cancelled order can't be deleted, used as a basket or resubmitted (BasketAlreadySubmittedError.stateincludesCANCELLED).- The revenue, statistics, export and gift-aid queries list
SUBMITTED,PACKINGandDISPATCHEDexplicitly, so a cancelled order drops out of them. Figures already calculated for past periods aren't recalculated. - It also drops out of the admin's Active and All Orders lists and appears under Cancelled Orders. The customer still sees it in their order history, with the status "Your order has been cancelled".
Returns (RMA)¶
A return records items coming back from a DISPATCHED or DELIVERED order. Staff record returns in the admin, API or MCP; customers can ask for one from their order page (see Customer return requests). It moves through:
PENDING_APPROVAL ──approve──▶ REQUESTED ──receive──▶ RECEIVED ──refund──▶ REFUNDED
│ │ │
└──────────reject──────────┴─────────────────────┴──────▶ REJECTED
Staff-recorded returns start at REQUESTED. REFUNDED and REJECTED are final. An order that hasn't been dispatched yet should be cancelled instead.
Data¶
Returns live in their own orderReturns collection (OrderReturn), scoped by siteId (and
carrying catalogueId), with indexes on siteId + state + created, siteId + orderId and
siteId + number. Only ReturnServiceImpl touches OrderReturnRepository; everything else goes
through ReturnService, which takes the caller's SiteContext, so a return on another site is
reported as not found. The old, unused ReturnOrder class was removed.
| Field | |
|---|---|
number |
<orderNumber>-R<n>, e.g. 1001-R1 |
lines |
order line id, SKUs, quantity and amount (the line's share of its discounted price, pro rata by quantity) |
refundAmount |
sum of the line amounts: what a refund pays by default |
refundedAmount |
what was actually refunded |
restocked |
whether the units went back into stock on receipt |
reason / rejectionReason |
why it came back / why it was turned down (sent to the customer) |
source |
STAFF or CUSTOMER |
customerReason / customerComments |
the reason the customer picked (DOES_NOT_FIT, FAULTY_OR_DAMAGED, NOT_AS_DESCRIBED, WRONG_ITEM, CHANGED_MIND, OTHER) and what they wrote |
history |
every step, with who did it and an optional note |
Each step is also written to the order's activity log (RETURN_REQUESTED, RETURN_RECEIVED,
RETURN_REFUNDED, RETURN_REJECTED).
Rules¶
- Create: quantities per order line, each at most what was ordered less what earlier returns (other than rejected ones) already cover. Subscriptions, virtual gift cards, gift card top-ups and donations can't be returned. A reason is required.
- Receive: optionally restocks each returned SKU (variant SKU where there is one) with
InventoryService.increaseStockon the default warehouse. - Refund: only from RECEIVED. Runs the order's
refundOrderpipeline for the return's value, or a different amount (e.g. less for damaged goods). The amount can't exceed what's left to refund on the order, and the payment must be CAPTURED, PARTIAL_REFUND or PAID. An atomicrefundInProgressclaim on the return stops two refunds of the same return running at once. If the provider doesn't refund, the return stays RECEIVED. - Reject: from REQUESTED or RECEIVED, with a reason that's sent to the customer. Rejected quantities become returnable again.
Emails¶
One template, email/orderReturnUpdate (HTML and text), switches on returnState. It's sent when
a return is requested, refunded or rejected, and lists the returned items and the amount.
Config key (order-return-emails) |
Default | |
|---|---|---|
order-return-emails-enabled |
true |
Send return emails |
order-return-email-subject |
Your return {1} for order #{0} |
{0} order number, {1} return number |
order-return-email-template |
orderReturnUpdate |
Template under email/ |
Where to find it¶
| Admin | Manage Orders → Returns lists returns with a state filter (customer requests are marked). A dispatched order's page has a Returns panel and a Create return form (quantity per line, reason). Each return has a page with its lines, history and the Mark as received (with restock), Refund (admins only) and Reject actions. Fulfillers can do everything except refund. |
| REST | GET/POST /v1/admin/returns, GET /v1/admin/returns/{returnId}, and POST .../receive, .../refund (with dryRun) and .../reject. Errors are 409 with a ReturnError message. |
| MCP | returns (mcp:read: list, get; the customer's name and email need mcp:write) and manageReturns (mcp:write: create, receive, reject; refund also needs mcp:admin, an idempotencyKey, respects mcp-refund-limit and supports dryRun). opsInbox counts returns to receive and to refund. |
Customer return requests¶
Customers can ask to return items from the order page (/o/{orderId}/{code}, reached from their
order emails, or from their account order history when logged in). It's off until a shop turns it
on.
Turning it on for a shop
- Apply the Customer return requests upgrade under Platform Upgrades. It adds the
orderReturnRequestextension to the order confirmation page, below the order summary. New installs have it already. - Set the site config (category
customer-returns):
| Key | Default | |
|---|---|---|
customer-returns-enabled |
false |
Show the "Return items" form |
customer-return-window-days |
30 |
Days after dispatch a customer can ask; 0 for no limit |
customer-returns-require-approval |
true |
Requests wait for staff approval; when off they're accepted straight away |
customer-return-instructions |
empty | What to do once accepted, e.g. where to post the items; in the email and on the order page |
customer-return-staff-email |
none | Address to email when a customer asks |
What the customer sees. The orderReturnRequest extension checks the visitor may see the
order (the confirmation code from the link, or the logged-in owner; anyone else sees nothing). It
lists the order's returns and their progress, shows the return instructions once a return is
accepted, and, when the order is eligible, offers a "Return items" form: a quantity per line (up to
what's left to return), a reason and optional comments. The form uses a native <details>
element, so it works in both themes without Bootstrap JS. Posting it creates the return and
redirects back to the order page with ?returnRequested=<number>, so a refresh doesn't send it
twice. An order is eligible when customer returns are on, it's DISPATCHED or DELIVERED, it's within
the window (counted from the order's DISPATCHED status change, or its dispatch tracking, or failing
that its submission), and something is left to return. Otherwise the panel says the window has
closed or everything is already being returned.
What staff see.
- A
returnRequestedEventin the admin notifications feed, linking to the return. It holds no personal data. Turn it off withsupressedAdminNotifications. - An email to
customer-return-staff-email, if set (email/orderReturnRequestedStaff). - The return under Manage Orders → Returns, marked Customer, showing the reason and comments. Approve and send instructions moves it to REQUESTED and emails the customer the return instructions. Decline rejects it with a reason that's emailed to the customer.
opsInboxcounts returns to approve; MCPmanageReturns approve; RESTPOST /v1/admin/returns/{returnId}/approve.
Emails to the customer. The orderReturnUpdate template covers each step: request received
(PENDING_APPROVAL), accepted with the instructions (REQUESTED), refunded and declined.
Not covered yet¶
- Customers can't cancel their own request; staff decline it.
- The request form isn't in the customer REST API, only on the storefront page.
- The delivery charge isn't included in a return's value; refund it by passing an
amount. - Revenue reports still count the original sale; the refund shows on the payment.
Pipeline validation¶
orderStateValidationProcessor and paymentStateValidationProcessor return validationFailed
when the order or payment is in the wrong state. The annotated pipeline executor stops on that
result, just as it does on error. So dispatching an order that isn't PACKING, or refunding a
payment that isn't captured, changes nothing and reports failure.
If a site has no validStates configured for a pipeline, the processors fall back to the
defaults in defaultProcessors.json: packOrder accepts SUBMITTED, dispatchOrder accepts
PACKING, cancelOrder accepts SUBMITTED or PACKING, and refunds accept PAID, CAPTURED or
PARTIAL_REFUND.