Skip to content

Marketplace Catalog Model

Purpose

The four entities behind every marketplace listing - what each one owns, who's allowed to touch it, and how a customer's cart line traces back to a specific seller's stock at a specific warehouse. Written for the seller-marketplace-completion slice (Product Variant Administration, Seller Marketplace Listings, Warehouse Inventory Management, Seller Dashboard) - see Application Architecture for where this sits in the wider backend.

The four entities

Product                     - shared marketplace/catalog product identity (Admin-owned)
  └─ ProductVariant          - catalog variation, e.g. Size/Colour/Model (Admin-owned)

SellerProduct                - one seller's offer/listing against a Product (+ optional Variant)
  ├─ seller SKU, price, discount price, listing status
  └─ SellerInventory[]        - stock for this SellerProduct at one Warehouse
Entity Owned by What it represents
Product Admin The shared catalog identity - name, slug, description, category, images. Multiple sellers can list against the same Product.
ProductVariant Admin A catalog-level variation of a Product (Size/Colour/Model). AttributesJson holds free-form key/value pairs, e.g. {"size":"XL","color":"Blue"} - deliberately not a normalized attribute schema in this phase.
SellerProduct Seller (the listing's own seller) A seller's offer against a Product (+ optional ProductVariant): seller SKU, price, discount price, listing status (Draft/Active/Inactive/OutOfStock/Suspended).
SellerInventory Seller (same seller as the SellerProduct) Stock for one SellerProduct at one Warehouse: AvailableQuantity, ReservedQuantity, DamagedQuantity, ReorderLevel.

Why this split exists

Before this slice, the seller portal created and edited Product rows directly (/products/seller/me, productsApi.create/update/delete/publish) - a single-seller model where "the product" and "the listing" were the same row. The marketplace model separates what is being sold (Product + ProductVariant, catalog master data) from who is selling it and on what terms (SellerProduct + SellerInventory, seller-owned). This is what makes multiple sellers able to list the same catalog Product independently, each with their own SKU, price, and stock.

The legacy Product CRUD endpoints and seller UI code path still exist (backward compatibility - admin product management and the storefront still read Product directly) but the current seller-facing UI (frontend/src/pages/Seller/Products/, frontend/src/pages/Seller/Inventory/) only talks to the marketplace endpoints (/api/marketplace-catalog/*), never the legacy ones.

Seller listing workflow

Admin creates/owns Product (+ optional ProductVariant)
Seller searches the catalog, selects a Product (+ Variant if one exists)
Seller creates a SellerProduct: seller SKU, price, optional discount price
   - one active listing per seller/product/variant (enforced in the controller,
     see MarketplaceCatalogController.CreateSellerProduct)
   - seller SKU unique per seller (DB unique index: SellerId + SellerSku)
Seller adds SellerInventory at one of their own Warehouses
   - a seller can never create inventory against another seller's warehouse
     (ownership check on every write)
Seller activates the listing (Draft -> Active)
Listing appears in customer-facing product search/add-to-cart
   (CartController resolves a default SellerProduct per Product when the
   customer doesn't specify one - see GetDefaultSellerProductForProductAsync)

A seller can never mutate the shared Product/ProductVariant catalog through this flow - only Admin can create/update/deactivate a ProductVariant (POST/PUT /api/marketplace-catalog/products/{productId}/variants, PUT /api/marketplace-catalog/variants/{variantId}, POST /api/marketplace-catalog/variants/{variantId}/deactivate). A seller only selects an existing Product/ProductVariant when creating a SellerProduct.

Product, variant, and SKU are immutable on a SellerProduct once created - editing a listing only ever changes price/discount/status. To sell a different product or variant, a seller creates a new listing rather than repurposing an old one (this is also why deactivating, not deleting, is the normal way to retire a listing - see below).

Warehouse inventory

Each SellerInventory row belongs to exactly one SellerProduct + one Warehouse (DB unique index: SellerProductId + WarehouseId - a seller cannot create two inventory rows for the same listing at the same warehouse). A listing can have inventory at multiple warehouses.

ReservedQuantity is system-managed - the seller UI cannot set it. The seller-facing inventory-update contract (PUT /api/marketplace-catalog/inventory/{id}, SellerInventory.UpdateStock) only accepts AvailableQuantity, DamagedQuantity, and ReorderLevel. ReservedQuantity is exclusively mutated by three domain methods, all reachable only from the checkout/payment pipeline (see PR #4's integrity notes):

Method Called by Effect
Reserve(qty) Razorpay order creation ReservedQuantity += qty
CommitReservation(qty) Payment verified successful ReservedQuantity -= qty, AvailableQuantity -= qty
Release(qty) Payment failed / cancelled ReservedQuantity -= qty (clamped at 0), AvailableQuantity untouched

If a seller's stock edit would reduce AvailableQuantity below the currently reserved amount, UpdateStock rejects the edit outright (InvalidOperationException409 RESERVATION_CONFLICT) rather than silently shrinking or clearing an active reservation. The invariant ReservedQuantity <= AvailableQuantity (and therefore SellableQuantity = AvailableQuantity - ReservedQuantity >= 0) holds after every mutation path. See backend/tests/ECommerce.Tests/Domain/SellerInventoryTests.cs for the full reservation-protection test suite, and Api/SellerInventoryEndpointTests.cs for the controller-level equivalent.