Skip to content

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-driven POST /api/orders/seller/{id}/cancel endpoint 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.