Orders and fulfilment
What checkout writes, the four order states and the transitions the platform allows, exactly what a cancel moves, why deletes are blocked, and the emails your customers get.
An order is the record of money that has already moved. By the time you see a
row in /admin/ecommerce/order, the buyer has been debited, your platform
wallet has been credited and stock has been decremented. Nothing in the admin
panel is an "approve payment" step.
That framing matters, because it means every status change you make from here is either a fulfilment note or a refund.
What checkout writes
POST /api/ecommerce/cart/checkout runs the whole cart inside one database
transaction. Everything below either all happens or none of it does.
-
Gate checks. Rate limit, then the
order_ecommerceKYC feature. An under-verified customer is refused here, before any product is loaded. -
Per-line validation. Product exists, is active, and — for physical items — has enough stock. Any discount is locked, checked against its product, its validity window, its usage cap and this customer's history.
-
Pricing. Subtotal is
price × quantity. The discount comes off. Shipping is added once per checkout, only if the cart contains a physical product and shipping is enabled. Tax is applied to the discounted subtotal. -
Wallet check. Lines are grouped by
walletType+currency. Each group's wallet is locked (in a deterministic order, so concurrent checkouts cannot deadlock) and the aggregated total is checked before a single debit. -
Persist. One
ecommerce_orderrow per product, each with its own order item, plus a wallet debit, the platform revenue credit, the shipping-and-tax pass-through credit, the stock decrement, the discount usage record and the shipping address. -
Status.
DOWNLOADABLEorders are writtenCOMPLETED;PHYSICALorders are writtenPENDING.
A cart with three different products produces three order rows, each with its own id, total and status. There is no parent "cart order" and no shared order number. Shipping is charged once across the whole checkout — usually landing on whichever physical line is processed first — so the shipping figure on one order will not match the others.
Everything after the commit is best-effort and cannot roll the sale back:
the OrderConfirmation email, the in-app notification, and affiliate rewards
under the ECOMMERCE_PURCHASE condition. A mail outage costs you a receipt,
not an order.
What is on an order
| Field | Meaning |
|---|---|
id |
UUID. This is the order number — it is what the confirmation email prints |
userId |
The buyer |
status |
PENDING, COMPLETED, CANCELLED or REJECTED |
subtotal |
Price × quantity, before discount |
discount |
Amount taken off |
shippingCost |
Charged once per checkout |
tax |
Applied to the discounted subtotal |
total |
What the buyer was actually debited |
currency, walletType |
The wallet that paid |
shippingId |
The shipment assigned to it, if any |
These are written once, at sale time, and are never recomputed. That is deliberate: an earlier build re-derived the receipt from current settings and emailed coupon users the full price. Reports, receipts and refunds all quote the row.
The status machine
| From | You may move it to | Notes |
|---|---|---|
PENDING |
COMPLETED, CANCELLED, REJECTED |
The only state with normal transitions |
COMPLETED |
— | Terminal by default |
CANCELLED |
— | Terminal |
REJECTED |
— | Terminal |
CANCELLED and REJECTED both refund. They differ only in the wording of the
customer's email — use CANCELLED when the buyer asked and REJECTED when you
declined the order.
There is no un-complete. Once a physical order is COMPLETED the normal
transition table is empty, and a refund from that state requires an explicit
override flag (allowRefundFromCompleted) that the admin screens do not send.
Mark an order complete when it has actually shipped, not when you start picking
it.
That override exists for genuine return-after-delivery. It is an API-level
choice on PUT /api/admin/ecommerce/order/{id}/status, and it was made
explicit precisely so that a refund can never be the side effect of a careless
status flip. Attempting the transition without it returns a 400 that tells you
the flag exists.
Bulk status changes
The orders table supports bulk status updates, with one restriction: every
selected order must currently be PENDING. If one is not, the whole batch is
refused naming the offender, before anything is written. A bulk cancel runs the
same refund logic as the single-order door — buyer refund, revenue reversal and
stock restoration all included.
What a cancel actually moves
Reversal is handled in one place for all three admin doors (per-order status, per-order update, bulk status), so they cannot disagree. In order:
-
The payment is located by the idempotency key the checkout wrote. If there is no payment, the operation fails before the status changes — the order is never left cancelled with the buyer's money still gone.
-
Platform revenue is reversed. The discounted subtotal is debited from the Super Admin wallet, but only if it was actually credited.
-
Shipping and tax are reversed. They were credited separately as a pass-through. Skipping this would mint money, because the buyer is about to be refunded the full amount.
-
The buyer is credited the full original debit — subtotal less discount, plus shipping, plus tax.
-
Stock is restored for every physical item on the order.
Every leg carries its own idempotency key and its own ledger reference, so a reversal that half-completed and was retried pays nobody twice. A leg that has already run is skipped and logged rather than repeated.
There is no partial refund, no per-item refund and no restocking fee. If you need to return part of an order, cancel it and re-sell what the customer is keeping.
Why you cannot delete a paid order
Delete on the orders table removes the row and nothing else. On an order that has been paid for and not refunded, that destroys the only record of the charge while the money stays with you, leaves the stock off the shelf, and erases the purchase from the buyer's history.
So the delete doors refuse. Any PENDING or COMPLETED order that has a
payment and no matching refund is blocked, by id, with an explicit instruction:
Order
<id>has been paid for and not refunded. Cancel or reject it first — that refunds the buyer and restores the stock — then delete it.
Cancel first, then delete. Deletes are soft and restorable in any case.
Working an order
Open a row to reach /admin/ecommerce/order/<id>. From there you can:
- Change status — the fulfilment action, and the refund action.
- Add or correct the shipping address. Creates one if the order has none, updates it otherwise. Phone numbers are normalised to E.164 for you, so whatever format you type is accepted.
- Assign a shipment. Links an existing shipment record to the order. See Shipping.
- Set download options for digital items — licence key, download link, or both, plus instructions. See Digital delivery.
- Export the order as CSV or JSON, or produce a printable summary.
A sane physical-goods loop is: pick and pack → create or pick a shipment and
assign it → mark the order COMPLETED → move the shipment through TRANSIT to
DELIVERED as it travels.
Emails and notifications
| Event | Template | Trigger |
|---|---|---|
| Order placed | OrderConfirmation |
Checkout, after the transaction commits |
| Status changed | OrderStatusUpdate |
Any admin status change, single or bulk |
Both go through core's mail queue and both are edited in System → Notification templates. The confirmation includes the order id, date, product, quantity, subtotal, discount, shipping, tax and total — quoted from the order row, so it always matches the charge.
Mail failures are caught and logged. A status change still applies, and a refund still happens, if the mail server is down.
The order-confirmation notification's View Order action points at
/ecommerce/orders/<id> (plural). The real customer page is
/ecommerce/order/<id> (singular), so that link 404s. Customers reach their
orders fine from the storefront menu; only the notification action is wrong.
What the customer sees
/ecommerce/order— their order history./ecommerce/order/<id>— one order, with its items, totals and address, plus the download panel for digital items.- Order tracking, which builds a timeline from the order's status and its assigned shipment's load status.
/ecommerce/shipping— every shipment attached to one of their orders.
Customers cannot cancel their own orders. Every cancellation is an operator action, which is what makes the refund path auditable.
Next: Digital delivery or Shipping.