Files
marketplaces/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md
sdarbinyan c91b75c036 docs: backend contracts for Phases 2-4 (orders/notifications, catalog/offer, connectors)
Continues the Phase 1 contract doc with the same wire-contract-only style.
All three build on Sprint 0.1's answered decisions - no further business
input needed to start implementation once backend ownership is confirmed:

- Phase 2: canonical Order/OrderLine/Fulfillment/OrderEvent per the unified
  multi-seller decision (one Order, per-seller Fulfillment groups), event
  bus, Notification Center contract.
- Phase 3: Product/Offer split, InventoryRecord, publish-time executability
  validation (the mechanism behind "no branch may distinguish an inspector
  from a normal buyer").
- Phase 4: generic config-driven connector framework per the "no fixed
  marketplace list" decision - onboarding a new partner is configuration
  against a fixed pipeline, not a bespoke integration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:02:52 +04:00

5.4 KiB
Raw Blame History

Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability

Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 3 (Sprints 3.13.3). The largest structural change in the programme — nothing about multi-seller commerce works without it.

Status: ready to build. No open decisions block this phase.


1. Why this exists

Today price, stock and currency hang directly off a single admin Product mock domain, unrelated to the live storefront Item domain. A product cannot have two sellers, two prices, or two stock levels. Offer/Listing does not exist in any form.

2. The two-layer split

Product describes the item itself (content). Offer describes one seller's commercial proposition against that product (price, stock, currency, status). One product, many offers.

interface Product {
  id: string;
  marketplaceId: string;
  categoryId: string;
  brand?: string;
  title: string;
  description: string;
  attributes: Record<string, unknown>;
  media: string[];
  status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
}

interface Variant {
  id: string;
  productId: string;
  sku: string;
  barcode?: string;
  optionValues: Record<string, string>; // e.g. { color: 'red', size: 'M' }
  dimensions?: { weight?: number; length?: number; width?: number; height?: number };
}

interface Category {
  id: string;
  marketplaceId: string;
  parentId: string | null;
  slug: string;
  attributesSchema: Record<string, unknown>;
  order: number;
  seo: { title?: string; description?: string };
}

interface Offer {
  id: string;
  marketplaceId: string;
  sellerId: string;
  variantId: string;
  sellerSku: string;
  price: Money;          // Money type from Phase 1 contract
  stockPolicy: 'track' | 'no_track' | 'preorder';
  status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
  publishedAt?: string;
  executabilityChecked: boolean; // see §5
}

interface PriceHistory {
  offerId: string;
  price: Money;
  changedBy: string;      // user id or 'sync:{connectorId}'
  changedAt: string;
}

3. Inventory

interface InventoryRecord {
  offerId: string;
  available: number;
  reserved: number;
  sold: number;
  warehouse?: string;
  source: 'manual' | 'feed_sync' | 'connector';
}

interface StockReservation {
  id: string;
  offerId: string;
  qty: number;
  reason: 'checkout' | 'pre_payment';
  expiresAt: string; // TTL
  released: boolean;
}

Invariants:

  • available, reserved, sold are counted separately, never derived from one another implicitly.
  • Reservations are created at checkout or pre-payment (tenant-configurable strategy) and expire by TTL, releasing reserved back to available.
  • Seller feed stock updates are an idempotent upsert — a repeated webhook must not double-decrement.
  • Oversell (a sale exceeding available) routes to a dedicated incident queue, never silently hidden or auto-corrected.

4. Lifecycle

draft -> moderation -> published -> paused/archived

Applies independently to both Product and Offer. Wires to the already-existing (mock) Admin Moderation module — no new frontend module needed, just a real gateway behind ADMIN_MODERATION_GATEWAY (token already added this session).

5. Publish-time executability

An offer that cannot actually be fulfilled must not be publishable. Before allowing status: 'published', the backend validates:

  • The offer has a valid Fulfillment type it can realistically satisfy (see Phase 2 contract Fulfillment.type).
  • Stock policy is track with available > 0, or no_track/preorder explicitly.
  • Required attributes for the offer's category (Category.attributesSchema) are present.

This is the mechanism behind the plan's §3.6/§10.2 requirement: no branch anywhere may distinguish a normal buyer from an inspector. The only way to guarantee that is to make every published offer genuinely executable at publish time, not to special-case checkout behavior later.

6. Bulk import

POST /api/admin/v2/products/bulk-import
Content-Type: multipart/form-data (CSV) or application/json (array)

Response returns a preview of validation errors before anything is applied — required-field validation, category-attribute validation, duplicate-SKU detection — with a separate POST .../bulk-import/{importId}/apply to commit after review.

7. Endpoints

GET    /api/admin/v2/products?marketplaceId=&status=&search=&page=&pageSize=
GET    /api/admin/v2/products/{id}
POST   /api/admin/v2/products
PATCH  /api/admin/v2/products/{id}
GET    /api/admin/v2/offers?productId=&sellerId=&status=
POST   /api/admin/v2/offers
PATCH  /api/admin/v2/offers/{id}
POST   /api/admin/v2/offers/{id}/publish   -> runs §5 executability check, 422 with details[] on failure
GET    /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId=   -- "find any offer by internal SKU, seller SKU, product ID, or external mapping" per plan §2.1

Replaces AdminProductsLocalGateway behind ADMIN_PRODUCTS_GATEWAY (token already wired this session).

8. What the frontend will start doing once this ships

  • Unify the admin mock product domain with the live storefront Item domain — currently two unrelated shapes.
  • Multi-seller product page: same product card, multiple offers/sellers/prices — undefined behaviour today.
  • Wire the Moderation module to real lifecycle transitions instead of mock data.