Seller Order Fulfilment¶
Purpose¶
How a paid multi-seller order actually gets fulfilled: the per-seller state machine, tracking, cancellation with inventory restoration, and how the parent order's status is derived from its seller sub-orders. Builds on Marketplace Catalog Model (Product vs ProductVariant vs SellerProduct vs SellerInventory) and PR #4's reservation/commit pipeline (see Application Architecture).
SellerOrder lifecycle¶
Processing -> Confirmed -> Packed -> ReadyToShip -> Shipped -> Delivered
Cancellation (Processing or Confirmed only):
Processing -> Cancelled
Confirmed -> Cancelled
Every forward transition is a single domain method on SellerOrder
(backend/src/ECommerce.Domain/Entities/SellerOrder.cs) that only accepts one specific
predecessor status:
| Method | From | To | Notes |
|---|---|---|---|
Confirm() |
Processing | Confirmed | |
MarkPacked() |
Confirmed | Packed | |
MarkReadyToShip() |
Packed | ReadyToShip | |
Ship(carrier, trackingNumber, trackingUrl) |
ReadyToShip | Shipped | trackingNumber required |
Deliver() |
Shipped | Delivered | |
Cancel(reason) |
Processing or Confirmed | Cancelled | Shipped/Delivered/already-Cancelled all rejected |
Calling any method from the wrong status throws - never a silent no-op, never an arbitrary jump.
This is also what makes every action idempotent-safe: a duplicate Confirm() call (e.g. a
client retry after a network timeout) throws on the second attempt rather than reapplying a side
effect, so ConfirmedAt never changes, tracking is never overwritten by a stale retry, and -
critically - cancellation's inventory restoration (below) can never run twice.
Why a dedicated SellerOrderStatus enum¶
The parent Order already had its own OrderStatus (Pending/Processing/Shipped/Delivered/
Cancelled/Refunded), shared with SellerOrder before this slice. Extending that shared enum with
Confirmed/Packed/ReadyToShip would have put physical per-seller fulfilment substates onto the
parent order's status vocabulary too - a multi-seller aggregate order is never itself "Packed."
SellerOrderStatus is a separate enum for exactly this reason - see the type's own XML doc in
Enums.cs.
Tracking¶
CarrierName, TrackingNumber, TrackingUrl are recorded server-side only when Ship() is
called, and only if TrackingNumber is non-blank (TrackingRequiredException /
TRACKING_REQUIRED otherwise). Carrier is free text - the seller UI suggests Delhivery/Blue
Dart/DTDC/India Post/Shiprocket/Other, but nothing is validated against a fixed list, and no
courier API is called in this slice - tracking is recorded information only, not a live
integration.
Parent-order aggregation¶
Order.RecalculateStatusFromSellerOrders() (called after any SellerOrder reaches Delivered or
Cancelled - never after Confirm/Pack/ReadyToShip/Ship) implements a deliberately minimal rule:
- A terminal parent (already Delivered or Cancelled) is never reopened.
- If every SellerOrder is Cancelled, the parent becomes Cancelled.
- If every non-cancelled SellerOrder is Delivered (and at least one exists), the parent becomes Delivered.
- Otherwise the parent is left as-is.
| Seller A | Seller B | Parent |
|---|---|---|
| Delivered | Shipped | unchanged (still Processing) - not every seller order is Delivered yet |
| Delivered | Delivered | Delivered |
| Cancelled | Delivered | Delivered - the only non-cancelled seller order delivered |
| Cancelled | Cancelled | Cancelled |
| Cancelled | Processing | unchanged - still waiting on the active seller |
Deferred improvements (deliberately out of scope for this slice)¶
- No "partially delivered" parent status. One seller Delivered and another still Shipped leaves the parent at Processing - there's no status this enum can express for "some sellers done, others not." A future slice could add one if this becomes a real support pain point.
- Whole-order customer cancellation does not restore inventory.
Order.Cancel(reason)(the pre-existing customer-facing "cancel my whole order" endpoint) still only cascades to cancellable sub-orders without restoring their committed stock - only the seller-drivenPOST /api/orders/seller/{id}/cancelendpoint added in this slice does that. Unifying the two paths is a reasonable follow-up, not done here to keep this slice's blast radius to the seller fulfilment flow specifically. - Packed/ReadyToShip cannot be self-cancelled by the seller. Once goods are packed/staged,
cancelling needs an operational/support path this slice doesn't build - see
CancellationNotAllowedException.
Cancellation and stock restoration¶
Cancelling a SellerOrder (CancelSellerOrderCommandHandler in OrderFeatures.cs) restores
whatever stock payment had already committed against it - inventory restoration only, no money
refund (that's an explicitly deferred, separate settlements slice):
Checkout: SellerInventory.Reserve(qty) ReservedQuantity += qty
Payment success: SellerInventory.CommitReservation(qty) ReservedQuantity -= qty, AvailableQuantity -= qty
Seller cancels: SellerInventory.RestoreStock(qty) AvailableQuantity += qty
RestoreStock only ever touches AvailableQuantity - by cancellation time,
CommitReservation has already zeroed out ReservedQuantity for these specific units, so there
is nothing left to "release." Each SellerOrderLine stores the exact SellerInventoryId it was
committed against (snapshotted at checkout-completion time, alongside SellerSku/VariantName),
so restoration goes back to precisely the warehouse row that was decremented - never a different
warehouse, and never another seller's stock. Lines from the legacy (pre-marketplace) checkout path
with no SellerInventoryId fall back to restoring Product.Stock directly.
Idempotency: SellerOrder.Cancel() only succeeds from Processing or Confirmed - a second
cancel attempt on an already-cancelled order throws CancellationNotAllowedException (or, if it
had shipped in between, OrderAlreadyShippedException) before the handler's restoration loop
ever runs again. Stock cannot be restored twice, by construction, not by an extra "already
restored" flag.
Error codes¶
| Code | HTTP | Meaning |
|---|---|---|
SELLER_ORDER_NOT_FOUND |
404 | No such seller order |
SELLER_ORDER_FORBIDDEN |
403 | Not this seller's order, and caller isn't Admin |
INVALID_ORDER_TRANSITION |
409 | Wrong current status for the action requested |
TRACKING_REQUIRED |
400 | Ship() called with a blank tracking number |
CANCELLATION_NOT_ALLOWED |
409 | Cancel attempted from Packed/ReadyToShip/Delivered/already-Cancelled |
ORDER_ALREADY_SHIPPED |
409 | Cancel attempted after Shipped - the single most common rejection case, given its own code |
Never a raw EF/MySQL error - see ExceptionHandlingMiddleware.cs.
Seller fulfilment runbook¶
Where does a seller check their own order? /seller/orders (list, filterable by status,
searchable by order number) → /seller/orders/:id (detail, timeline, primary action button).
Read-only checks (safe anytime):
# As the seller (or Admin) - single order detail, ownership-checked
GET /api/orders/seller/{sellerOrderId}
# As Admin only - filter across all sellers
GET /api/orders/admin/seller-orders?status=Shipped
GET /api/orders/admin/seller-orders?sellerUserId=<guid>
"Why can't this seller cancel their order?" Check status from the detail response above -
only Processing/Confirmed are cancellable. Packed/ReadyToShip need the packed-order support
path (not built in this slice); Shipped/Delivered are correctly rejected by design.
"Why didn't the parent order become Delivered?" List every SellerOrder under the parent
(GET /orders/{orderNumber} as the customer, or the seller-order detail endpoints per seller) and
check that literally every non-cancelled one is Delivered - one Shipped sibling is enough to
hold the parent back, by design (see "Deferred improvements" above).
"Was inventory actually restored after a cancellation?" Cross-check the cancelled
SellerOrder's items[].sellerProductId against
GET /api/marketplace-catalog/inventory (as the seller or Admin) - availableQuantity for that
listing's warehouse row should reflect the restored quantity. See
Marketplace Catalog Model → Warehouse inventory.
For a direct row-level check, a read-only SELECT * FROM SellerInventories WHERE Id = '<guid>'
against the MySQL database works too - see
Local Development for connecting to the local dev database.
Related pages¶
- Payments & Seller Settlements - what happens after Delivered: payment reconciliation, commission calculation, and the settlement lifecycle
- Marketplace Catalog Model - Product/ProductVariant/SellerProduct/SellerInventory
- Application Architecture - PR #4's reservation/commit pipeline this builds on