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.

7 min readUpdated 3 August 2026orders, refunds, fulfilment, checkout

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.

  1. Gate checks. Rate limit, then the order_ecommerce KYC feature. An under-verified customer is refused here, before any product is loaded.

  2. 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.

  3. 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.

  4. 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.

  5. Persist. One ecommerce_order row 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.

  6. Status. DOWNLOADABLE orders are written COMPLETED; PHYSICAL orders are written PENDING.

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:

  1. 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.

  2. Platform revenue is reversed. The discounted subtotal is debited from the Super Admin wallet, but only if it was actually credited.

  3. 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.

  4. The buyer is credited the full original debit — subtotal less discount, plus shipping, plus tax.

  5. 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.