Skip to content

Buylist Inventory Intake — Design

Date: 2026-08-17 Status: Implemented Supersedes a non-goal of: 2026-07-29-buylist-shopify-native-settlement-design.md ("Inventory intake of purchased cards into Shopify")

Problem

Settling a buy order paid the customer but never put the cards on the shelf. A merchant who bought 60 cards had to re-enter all 60 by hand in Shopify admin, or through the catalog singles page one card at a time.

Scope

An Add to Inventory button on a settled (paid) buy order stocks the cards that order bought: singles and sealed, across all registered games.

Non-goals: no change to settlement; no reconciliation of stock a merchant later edits in Shopify; no per-line quantity editing at intake time (quantities were fixed when the offer was accepted).

Decisions

Separate from settling, not part of it

Settling moves money; intake moves stock. They happen at different times — the payout happens with the customer at the counter, the stocking happens once the cards are sorted. Coupling them would also mean a long Shopify job hanging off the payout confirmation, so an inventory failure could cast doubt on a completed payout. Intake is therefore its own button, its own job, and its own sub-document (BuylistOrder.intake), with settlement code untouched.

Reuse both existing write paths; invent neither

  • SinglessyncService.syncSetDirect(setCode, { game, cardNumber, condition, quantity, finish }), the same call the catalog singles-page "add N to inventory" shortcut makes. It creates or updates the product, then _applyCardInventoryShortcutshopifyAPI.ensureVariantForConditionAndInventory creates the condition variant if needed and delta-adjusts stock.
  • SealedquickAddSealedProduct, the same call the sealed quick-add route makes.

This keeps one implementation of variant creation, barcode identity, condition pricing and publication targeting (§5.8).

quickAddSealedProduct extracted, and price made optional

It was route-local in routes/sealedProducts.js and always rewrote the variant price, setting pricingSource: 'manual'. Intake must never pass the buylist price: the only price this order knows is what the shop paid the customer, and writing that as the variant price would reprice the shelf at cost.

Moved to services/sealedQuickAddService.js with price optional:

callerpriceexisting productnew product
quick-add routesalways passedrewritten (unchanged behavior)priced as given
buylist intakeomitteduntouchedcatalog price via applySealedPricingRules, real pricingSource

Finish is passed through verbatim — no mapping table

The original design called for translating line.finish into a plugin finish id. Live data says don't:

collectiondistinct finish values
shopify_mtg_products.metafields.finish15 — Nonfoil, Etched Foil, Surge Foil, Galaxy Foil, …
shopify_mtg_products_variants.variants.finish22
shopify_pokemon_products*normal, holofoil, reverseHolofoil, 1stEditionNormal, 1stEditionHolofoil
shopify_riftbound_products_variantsnormal, foil

MTG has 22 catalog finishes, not the plugin's three ids (normal/foil/etched). A mapping table would collapse all 19 premium foils onto Normal — silently, since a wrong finish literal matches nothing rather than erroring (§5.4).

Both sides are already the same vocabulary: line.finish is copied verbatim from variants[].finish (buylistQuoteService.buildMatchResult), and resolveFinishLabel compares against metafields.finish, which buildVariantProducts sets from that same field. Passing it through gets an exact case-insensitive match.

Product limit enforced, not merely warned

There is no confirm step (a deliberate choice — the merchant wanted one click). The plan cap still holds: intake creates products exactly as POST /api/sync does and must not become a way around it. The run calls planService.checkProductLimit for the distinct new products it needs, stocks what fits, and marks the overflow skipped: 'product_limit'. Distinct products are admitted whole, so a merchant never gets a card's NM copies but not its LP ones. If the limit check itself errors, the run fails rather than creating unbounded products.

Re-runnable by design

intake.lines[] records every line's outcome. Only stocked is terminal — skipped and failed lines are retried on the next click, stocked lines are never touched again. A partial run is fixed by pressing the button again, not by hand reconciliation. Every field is schema-declared (§5.2) because a silently dropped status or lineIndex would make a re-run double-stock.

Records persist after every line, not once at the end. Batching them to the end means a process death at line 40 of 60 loses all 40 records, and the next run re-stocks those 40 — the shelf silently doubles. One small Mongo write per line is negligible against the Shopify round trips that line already cost.

Double-stocking: the three ways it could happen, and the guard for each

hazardguard
Two clicks / two tabs racingAtomic claim: the in-flight check is inside the same findOneAndUpdate that sets queued, so only one caller wins. A read-then-write here would let both through.
Worker dies mid-run, automatic retry re-entersattempts: 1 on the job (overriding the queue's attempts: 3 + 60s backoff) — a run that moves real stock should surface to the merchant, not silently re-enter. Combined with per-line persistence, any retry only picks up what never stocked.
Crashed run wedges the order in running foreverintake.heartbeatAt, refreshed on every per-line write. A claim also succeeds when the heartbeat is older than INTAKE_STALE_MS (10 min). Because the heartbeat is per line rather than per run, a 500-line order never looks dead while it is genuinely working. Verified against real MongoDB: fresh running/queued blocked; stale ones and completed/no-intake claimable.

A failed enqueue releases the claim (to failed), so Redis being down can't lock a merchant out of retrying.

A "stocked zero units" line is a failure, not a success

quickAddSealedProduct's create path skips the inventory add when Shopify returns no variant id, and reports no warning for it. Recorded as stocked, that line would be skipped by every future run, leaving the merchant permanently short those units — so quantityAdded === 0 is recorded as failed.

Rides the sync queue

buylist-intake is a job name on the existing mtg-sync queue, branched at the top of syncProcessor. It does the same kind of work as a sync, so it should share the sync worker's concurrency and Shopify rate limiter rather than compete with it. A fourth queue would mean a new worker, new Redis wiring and a deploy-shape change on both API and worker (§5.7).

BullMQ automatic retries are turned off for this job (attempts: 1) rather than merely unused — the queue's default is attempts: 3 with 60s exponential backoff, which a job on this queue inherits unless it says otherwise.

Game parity (§5.1)

Plugin-driven throughout; no if (game === …) anywhere in the new code.

gamesinglessealed
mtgmirroredmirrored (supportsSealed: true)
pokemonmirroredmirrored (supportsSealed: true)
riftboundmirroredmirrored (supportsSealed: true)

All three plugins report supportsSealed: true (verified in server/plugins/*/index.js). A future plugin without it skips its sealed lines with sealed_unsupported rather than failing the run. game is never defaulted — a line without one is skipped as unsupported_game (§5.5).

Surface

  • POST /api/buylist/orders/:id/intake — queue a run. buylistIntakeSchema (.strict(), empty body: what gets stocked is derived from the order's own lines) plus objectIdParamSchema on the param, so a malformed id is a 400 rather than a CastError surfacing as a 500. 409 if unsettled or already running.
  • GET /api/buylist/orders/:id/intake — poll; returns the sub-document plus a summary.
  • InventoryIntakeCard on the review page: button, progress, and a result panel that groups skips by reason.

Files

filechange
server/services/buylistIntakeService.jsnew — planning, limit application, per-line stocking
server/services/sealedQuickAddService.jsnew — extracted from routes/sealedProducts.js, optional price
server/models/BuylistOrder.jsintake sub-document
server/schemas/buylistOrder.jsbuylistIntakeSchema
server/routes/buylist.jstwo endpoints
server/queues/syncQueue.jsaddBuylistIntakeJob, BUYLIST_INTAKE_JOB
server/queues/processors/syncProcessor.jsjob-name branch → buylistIntakeProcessor
client/src/pages/buylist/InventoryIntakeCard.jsxnew
client/src/pages/buylist/BuylistReviewPage.jsxrenders the card for paid orders