Skip to content

Marketplace Logistics

Purpose

A vendor-neutral shipment/tracking layer that sits between seller fulfilment and delivery: once a seller order is packed and ready, a Shipment aggregate tracks it from booking through pickup, transit, and delivery, syncing back into the existing fulfilment pipeline as it goes. No real courier API is called anywhere in this codebase - no Shiprocket, Delhivery, Blue Dart, DTDC, or India Post integration exists. Builds on Seller Order Fulfilment and reads (never rewrites) Payments & Seller Settlements's settlement generation to optionally reimburse a seller's actual shipping cost.

Shipment flow

SellerOrder (ReadyToShip - see seller-fulfilment.md)
  |
  v
Shipment.Create()            snapshots Warehouse + Order.ShippingAddress, starts Draft
  |
  v
ILogisticsProvider.BookShipmentAsync()   vendor-neutral - Manual or Test provider only
  |
  v
Shipment.ApplyProviderResult()   records TrackingNumber/AwbNumber/EstimatedDeliveryDate/ShippingCost
  |
  v
ReadyForPickup -> PickupScheduled -> PickedUp  --------> SellerOrder.Ship(...)  (ReadyToShip -> Shipped)
  |
  v
InTransit -> OutForDelivery -> Delivered  ------------->  SellerOrder.Deliver() + Order.RecalculateStatusFromSellerOrders()

Cancellation is a separate branch, only reachable before pickup (Draft/ReadyForPickup/ PickupScheduled) - see "Cancellation" below.

Provider abstraction

ILogisticsProvider (ECommerce.Application/Common/Interfaces/ILogisticsProvider.cs) is the only seam through which a shipment gets booked, scheduled, or cancelled with "the courier". Nothing in the Domain or Application layer contains a real vendor's name, DTO shape, secret, or endpoint URL. Two implementations exist, both vendor-free:

  • ManualLogisticsProvider (LogisticsProvider.Manual) - the real day-one flow. Operations staff arrange the courier themselves (phone call, drop-off) outside this system; the provider's job is only to generate a platform tracking number (reusing the shipment number, since there is no real courier response to take one from) and a config-driven cost estimate (Marketplace:Logistics:Manual:BaseRate + PerKgRate * weight, EstimatedDeliveryDays). No AWB is ever produced by this provider.
  • MockLogisticsProvider (LogisticsProvider.Test) - fully deterministic, zero configuration dependency, used only by backend tests and the local E2E smoke test so assertions never depend on ManualLogisticsProvider's rate math. Never selectable from the API - LogisticsController always books through LogisticsProvider.Manual.

A real vendor adapter is intentionally not implemented in this slice. Adding one later means writing a new ILogisticsProvider implementation and registering it in ECommerce.Infrastructure/DependencyInjection.cs - no other layer should need to change.

AWB vs tracking number

AwbNumber is the courier's own consignment/airway-bill number - it may be null (e.g. ManualLogisticsProvider never has one to report). TrackingNumber is the platform-facing identifier customers and sellers actually use, and is always present once a shipment is booked. The two are deliberately separate fields; nothing in this codebase assumes they're ever equal.

Shipment domain model

Shipment (ECommerce.Domain/Entities/Shipment.cs) has no public mutable setters - every field changes only through a named method that validates its own precondition:

  • Creation (Create) snapshots the pickup warehouse (PickupWarehouseName/AddressLine1/ AddressLine2/City/State/PostalCode/Country - exactly the fields Warehouse has today; no ContactName/Phone were invented) and the delivery address (DeliveryFullName/Street/City/ State/PostalCode/Country/Phone - exactly OrderShippingAddress's fields) at creation time, so a later warehouse edit or address change never rewrites shipping history.
  • ApplyProviderResult is a second construction step, called immediately after Create once the provider has responded - a client can never supply AwbNumber/TrackingNumber/ ShippingCost directly.
  • Lifecycle: Draft -> ReadyForPickup -> PickupScheduled -> PickedUp -> InTransit -> OutForDelivery -> Delivered, plus Cancel (only before pickup). Every transition method accepts exactly one predecessor status and throws before any side effect - idempotent-safe by construction, the same pattern SellerOrder's fulfilment methods use.
  • ShipmentTrackingEvent is append-only: no update method exists anywhere on the type. Every transition (including creation) appends one event; the timeline a seller/customer sees is literally this list, oldest first.

Package validation

Weight and all three dimensions must be > 0. A sanity ceiling exists (1000kg, 500cm per dimension) purely to catch obviously-wrong input - it is not a real courier's weight/size limit, which this codebase deliberately has no knowledge of (see "Provider abstraction" above).

One active shipment per SellerOrder

A SellerOrder may have at most one active (non-Cancelled) shipment at a time, but a new shipment can be created after a previous one is cancelled. This is enforced at the application layer (CreateShipmentCommandHandler checks ILogisticsRepository.GetActiveBySellerOrderIdAsync before creating), not with a database unique index: a plain unique index on SellerOrderId would wrongly block re-shipment after cancellation, and MySQL has no partial/filtered unique index available to both the production MySQL runtime and the SQLite provider the test suite runs against. A genuine simultaneous double-submit for the same SellerOrder is an accepted, documented race window - contrast with SellerSettlement.SellerOrderId's real unique index, safe there because a settlement is permanently one-per-order and never re-created.

SellerOrder prerequisite

A shipment may only be created once its SellerOrder is ReadyToShip (not merely Packed) - SellerOrderNotReadyForShipmentException otherwise. This is the same fulfilment pipeline Seller Order Fulfilment already built; Logistics does not introduce a second, competing tracking system. A seller can still use the original manual "Ship Order" dialog (SellerOrderDetail.jsx) instead of creating a Shipment at all - both reach Shipped, but only the Logistics path produces a Shipment/tracking timeline.

SellerOrder synchronization

Two Shipment transitions reach into the existing fulfilment domain, reusing its methods verbatim rather than duplicating any logic:

  • MarkPickedUp - the earliest point where "a carrier physically has the package" is true - calls SellerOrder.Ship(serviceName, trackingNumber, trackingUrl), moving ReadyToShip -> Shipped.
  • MarkDelivered calls SellerOrder.Deliver() (Shipped -> Delivered) and then Order.RecalculateStatusFromSellerOrders() - the exact same parent-aggregation rule Seller Order Fulfilment already documents.

Both sync calls are guarded by the SellerOrder's own current status (only fired if still ReadyToShip/Shipped respectively) so a shipment that's somehow out of step with its SellerOrder never forces an invalid transition - it just skips the sync silently, since Shipment's own transition methods are already the idempotency gate (a repeat MarkPickedUp/MarkDelivered call throws before reaching the sync code at all).

Nothing in Logistics ever changes inventory quantities - creating, progressing, or cancelling a shipment has no effect on SellerInventory. Inventory is committed at checkout and restored only by SellerOrder.Cancel (see Seller Order Fulfilment); a cancelled shipment just clears the way for a new one against the same, still-committed, SellerOrder.

Cancellation

Shipment.Cancel only succeeds from Draft/ReadyForPickup/PickupScheduled - ShipmentCannotCancelException from PickedUp onward (a carrier already has the package), ShipmentAlreadyDeliveredException specifically if Delivered. CancelShipmentCommandHandler notifies the provider first (ILogisticsProvider.CancelShipmentAsync - a no-op that always succeeds for both implementations in this slice, since neither holds a real courier booking) then calls Shipment.Cancel. Cancelling a shipment does not cancel the underlying SellerOrder or touch inventory - the seller can immediately create a replacement shipment for the same SellerOrder (see "One active shipment per SellerOrder" above).

Shipping cost model

Two separate amounts exist and are never assumed equal anywhere in this codebase:

  • SellerOrder.ShippingCost - the customer-facing shipping charge allocated to that seller at checkout.
  • Shipment.ShippingCost - the authoritative logistics cost, computed by whichever ILogisticsProvider booked the shipment.

Marketplace:Logistics:SellerShippingReimbursed (bool, default false when absent) controls whether settlement generation reimburses the seller for the second amount. GenerateSettlementCommandHandler (in SettlementFeatures.cs) now looks up any non-cancelled Shipment for the SellerOrderId being settled: if the flag is true and a shipment exists, SellerShippingAmount = Shipment.ShippingCost; otherwise SellerShippingAmount = 0 (the platform keeps the entire customer shipping charge and reimburses nothing). This is evaluated once, at generation time, and never revisited - a shipment created or cancelled after a settlement already exists never retroactively changes it, let alone an already-Settled one.

Error codes

Code HTTP Meaning
SHIPMENT_NOT_FOUND 404 No such shipment
SHIPMENT_FORBIDDEN 403 Not this seller's shipment, and caller isn't Admin
SHIPMENT_ALREADY_EXISTS 409 An active shipment already exists for this seller order
INVALID_SHIPMENT_TRANSITION 409 Wrong current status for the action requested
INVALID_PACKAGE_DIMENSIONS 400 Weight/length/width/height not positive, or over the sanity ceiling
INVALID_SHIPPING_COST 400 Provider returned a non-positive cost
WAREHOUSE_FORBIDDEN 403 The given warehouse does not belong to the seller who owns the order
SELLER_ORDER_NOT_READY_FOR_SHIPMENT 409 SellerOrder is not yet ReadyToShip
TRACKING_REQUIRED 400 (reserved - Shipment never produces a blank tracking number itself)
SHIPMENT_ALREADY_DELIVERED 409 Action attempted after Delivered
SHIPMENT_CANNOT_CANCEL 409 Cancel attempted after pickup

Never a raw EF/MySQL error - see ExceptionHandlingMiddleware.cs.

A recurring EF Core pitfall this slice hit

Appending a new ShipmentTrackingEvent to an already-tracked Shipment.TrackingEvents collection (i.e. every lifecycle transition after the shipment's initial creation, once loaded fresh in a new request scope) triggers EF Core's known "misclassifies a new client-keyed child as Modified instead of Added" behavior, and threw DbUpdateConcurrencyException in every transition handler until fixed. Since ShipmentTrackingEvent has no update method anywhere (append-only by construction), any Modified entry of that type is by definition always a misclassified new one - LogisticsRepository.SaveChangesAsync flips every such entry to Added before saving, and every Logistics command handler saves through ILogisticsRepository (not IUnitOfWork directly) so this fix-up always runs.

Future webhook architecture (not implemented)

A real courier integration would eventually need an inbound webhook (courier -> platform) to push tracking updates instead of the seller manually clicking through each transition. That surface is not built or exposed in this slice - no unauthenticated/unsigned webhook endpoint exists anywhere in this codebase. When it is built, it should: (1) live behind its own signature verification (vendor-specific, so it belongs inside that vendor's future ILogisticsProvider implementation, not in LogisticsController), (2) translate the vendor's payload into the same MarkPickedUp/MarkInTransit/.../MarkDelivered commands this slice already exposes, so the domain model and SellerOrder-sync logic need no changes, and (3) never trust a courier-supplied status blindly enough to skip Shipment's own transition guards.

Frontend

  • Seller: /seller/logistics (grid of shipments, status filter, "Create Shipment" dialog that picks a ReadyToShip seller order + warehouse + package dimensions) and /seller/logistics/:id (detail: tracking timeline, package/pickup/delivery info, one context-sensitive primary action button per status, cancel while pre-pickup). The original manual "Ship Order" dialog on /seller/orders/:id still exists unchanged - a seller chooses one path or the other per order.
  • Admin: /admin/logistics (list, filterable) and /admin/logistics/:id - read-only operational visibility, matching AdminOrders' existing restraint (the seller operates their own shipments day to day).
  • Customer: the order-detail page (OrderDetailPage.jsx) renders a shipment tracking timeline under each seller's fulfilment card when one exists, with an external "Track shipment" link (target="_blank" rel="noopener noreferrer").