Medusa inventory: model Ingram drop-ship as a single stock location

Why now

Today’s SFF availability-filter experiment proved we need real inventory tracking, not a discovery-time filter. The iPage saved-search filter was opaque (excluded Between Two Fires despite 410 on-hand) and removing 16 books from the storefront based on it lost real catalog. Reverted, but the right answer is: discover everything, track inventory accurately, let the storefront UI show availability honestly (in-stock / out / preorder / backorder).

Today we also unblocked the rescrape/enrich path (just enrich <isbn>+ per ipage-extract-by-ean_id — see today’s work) which means inventory can sync independently of full catalog ingestion.

The model

Single virtual location representing Ingram’s drop-ship. Not 4 locations for PA/IN/OR/TN — those are Ingram’s internal routing, not anything Carrie operates. From the store’s perspective there’s one fulfillment source.

  • Name: Ingram (Drop-Ship) or Distributor
  • Address: One Ingram Blvd, La Vergne, TN 37086, US (Ingram’s actual primary US fulfillment center — real public address, not a fiction)
  • Linked to the existing Online Store sales channel

Per-variant inventory:

  • manage_inventory: true on the variant (currently false)
  • inventory_level = sum(on_hand across all 4 DCs) at the Ingram location
  • on_order (per-DC sum) stays in metadata.ingram_stock for surfacing in admin / future “available to backorder” UI

What this gives

  • Cart-time validation: Medusa rejects orders for books at 0 inventory unless backorder is allowed per-variant
  • Honest in-stock labels computed from real data instead of just “is the product visible”
  • Backorder UX: 0 on_hand + positive on_order → “Available to order, ships in 1-2 weeks”
  • Preorder UX: future pub_date → “Preorder, releases [date]”
  • Low-stock event hook (Medusa fires when inventory drops below threshold) — useful later for “reorder these from Ingram” alerts

What this avoids

  • Per-DC routing detail customers don’t care about
  • 4 inventory_level rows per book
  • Pretending we operate warehouses we don’t

Implementation

Two PRs, ~80 lines net in emporium + ~50 in catalog.

PR 1 (emporium): create location + wire inventory on import

  • Migration: create the Ingram (Drop-Ship) stock location at the real La Vergne TN address, link to Online Store sales channel
  • buildProductInput in catalog-import.ts:
    • Set manage_inventory: true on the variant
    • Set initial inventory_level against the Ingram location = sum(book.stock[*].onHand)
  • runCatalogImport’s “already exists by SKU” branch needs to update inventory_level too (currently it only flips sync_status to imported and does nothing in Medusa). New code path.
  • Admin: optional “Sync inventory” button on the Catalog Import settings page that re-runs only the inventory-update path against all imported books.

PR 2 (catalog): lightweight just sync-stock command

  • New entry point: just sync-stock <target> (defaults to the SFF searchId — or maybe Indie Vault once we have it)
  • Downloads CSV via existing download_csv.run() to get the ISBN list + price (no detail-page fetches — that’s what makes this “lightweight”)
  • Wait — CSV doesn’t include per-DC stock. Stock is on the detail page only. So sync-stock either:
    • (a) Hits the detail page per ISBN (basically the existing enrich flow but only extracting stock), or
    • (b) Hits iPage’s grid which has aggregate stock counts in the cell layout (need to verify)
  • Goes with (a) for simplicity unless (b) is meaningfully faster: ~15s per 100 books at concurrency 8.
  • Updates the queue DB stock column on each book
  • Emits a flag (per book?) when stock changes meaningfully so emporium’s sync can be selective
  • Cron-ready: should run hourly or every few hours, independent of full catalog ingestion

The freshness question

iPage stock at sync time → Medusa inventory. Customer order at T+2 hours might not be fulfillable if Ingram sold through. Two paths:

  1. Tighten sync cadence — hourly stock sync via cron. Each run is ~15s for 100 books (we know this from today’s live runs). Cheap.
  2. Validate on order placement — at checkout time, hit iPage’s product page for that ISBN and confirm stock before charging. Real-time but adds Imperva exposure and a synchronous hop in the checkout path.

Start with (1). Revisit (2) only if we hit real “sold a book Ingram doesn’t have anymore” incidents.

Order of operations

  1. Land catalog stack (PRs 7 already merged; #8 rename refactor pending)
  2. Decide SFF filter strategy — probably drop the in-stock filter, use top-N unfiltered (see today’s journal for the experiment + reversal)
  3. PR 1 (emporium) — location + import-side wiring
  4. PR 2 (catalog) — sync-stock command + cron
  5. Backfill: run sync-stock once across all 100 imported books to populate inventory from current stock data
  6. Verify storefront in-stock/out-of-stock labels reflect reality

Out of scope for this plan

  • cover-change-detection — separate plan, only matters when placeholder→final art rotation matters more than it does today
  • Indie Vault as a discovery source — depends on Carrie pulling the URL (indie-vault)
  • Order routing across multiple sources (Bookshop, Amazon, direct from publisher)
  • Real-time stock validation at checkout time
  • Reorder triggers / “low stock” alerts to Carrie